From e971d990708364b95f48aa1160fa199a054a17bb Mon Sep 17 00:00:00 2001 From: MengMengCode <227010654+MengMengCode@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:33:17 +0800 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- backend/internal/api/settings.go | 28 + backend/internal/cli/cli.go | 510 +++++++--- backend/internal/config/config.go | 18 + backend/internal/config/store_sqlite.go | 2 + backend/internal/server/server.go | 2 + backend/main.go | 29 +- frontend/src/components/AutoTranslate.tsx | 182 ++++ .../components/BrowserDialogTranslator.tsx | 19 + frontend/src/components/Dialog.tsx | 10 +- frontend/src/components/Layout.tsx | 4 + frontend/src/components/Sidebar.tsx | 49 +- frontend/src/contexts/LanguageContext.tsx | 72 ++ frontend/src/main.tsx | 13 +- frontend/src/pages/Login.tsx | 24 +- frontend/src/services/api.ts | 9 + frontend/src/utils/i18n.ts | 910 ++++++++++++++++++ install.sh | 365 ++++++- 17 files changed, 2068 insertions(+), 178 deletions(-) create mode 100644 frontend/src/components/AutoTranslate.tsx create mode 100644 frontend/src/components/BrowserDialogTranslator.tsx create mode 100644 frontend/src/contexts/LanguageContext.tsx create mode 100644 frontend/src/utils/i18n.ts diff --git a/backend/internal/api/settings.go b/backend/internal/api/settings.go index 2606724..0cc12e9 100644 --- a/backend/internal/api/settings.go +++ b/backend/internal/api/settings.go @@ -20,6 +20,34 @@ type LoginLog struct { var loginLogs = make([]LoginLog, 0) +// HandleLanguage returns or updates the global panel language. +func HandleLanguage(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{ + "language": config.NormalizeLanguage(config.AppConfig.Language), + }}) + case http.MethodPost, http.MethodPut: + var req struct { + Language string `json:"language"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonResponse(w, http.StatusBadRequest, APIResponse{Success: false, Message: "Invalid request body"}) + return + } + config.AppConfig.Language = config.NormalizeLanguage(req.Language) + if err := config.SaveConfig(); err != nil { + jsonResponse(w, http.StatusInternalServerError, APIResponse{Success: false, Message: "Failed to save language"}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: map[string]string{ + "language": config.AppConfig.Language, + }}) + default: + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Success: false, Message: "Method not allowed"}) + } +} + // RecordLoginLog adds a login attempt to the log (persisted to config) func RecordLoginLog(username, ip, userAgent string, success bool) { config.AddLoginLog(username, ip, userAgent, success) diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go index 8cd65f8..ca4588d 100644 --- a/backend/internal/cli/cli.go +++ b/backend/internal/cli/cli.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "time" @@ -26,18 +27,170 @@ const ( libvirtDefaultNetworkMarker = "/var/lib/clicd/kvm/default-network.created" ) +var cliEnglish = detectCLIEnglish() + +var cliTranslations = map[string]string{ + "重新加载配置失败": "Failed to reload config", + "请选择操作": "Select an action", + "再见": "Goodbye", + "无效选择": "Invalid choice", + "CLICD - LXC 容器管理器": "CLICD - LXC Container Manager", + "Web 面板": "Web panel", + "端口": "port", + "运行中": "running", + "已停止": "stopped", + "当前版本": "Current version", + "查看容器列表": "List containers", + "创建容器": "Create container", + "开机容器": "Start container", + "关机容器": "Stop container", + "重启容器": "Restart container", + "删除容器": "Delete container", + "重装容器系统": "Reinstall container OS", + "重置 Web 管理员密码": "Reset web admin password", + "启动": "Start", + "停止": "Stop", + "导入现有 LXC 容器": "Import existing LXC containers", + "检查并升级 CLICD": "Check and upgrade CLICD", + "卸载 CLICD": "Uninstall CLICD", + "系统信息": "System info", + "退出": "Exit", + "获取容器列表失败": "Failed to get container list", + "暂无容器": "No containers", + "容器": "Container", + "名称": "Name", + "状态": "Status", + "镜像": "Image", + "内存(MB)": "Memory(MB)", + "磁盘(GB)": "Disk(GB)", + "容器名称": "Container name", + "容器名称不能为空": "Container name cannot be empty", + "可用镜像": "Available images", + "镜像选择无效": "Invalid image selection", + "内存 (MB)": "Memory (MB)", + "磁盘 (GB)": "Disk (GB)", + "网络带宽 (Mbps)": "Network bandwidth (Mbps)", + "月流量 (GB)": "Monthly traffic (GB)", + "IO 速度 (MB/s)": "IO speed (MB/s)", + "额外 NAT 端口,多个用逗号分隔": "Extra NAT ports, comma-separated", + "正在创建容器": "Creating container", + "创建失败": "Create failed", + "创建成功": "created successfully", + "端口未分配": "port not assigned", + "密码已保存,请在 Web 面板中查看或重置": "Password saved. View or reset it in the web panel", + "开机失败": "Start failed", + "已开机": "started", + "关机失败": "Stop failed", + "已关机": "stopped", + "重启失败": "Restart failed", + "已重启": "restarted", + "开机": "start", + "关机": "stop", + "重启": "restart", + "删除": "delete", + "重装": "reinstall", + "确认删除容器": "Delete container", + "输入 yes 继续": "type yes to continue", + "已取消": "Cancelled", + "删除失败": "Delete failed", + "已删除": "deleted", + "确认重装容器": "Reinstall container", + "重装失败": "Reinstall failed", + "已重装": "reinstalled", + "新的管理员密码(至少 6 位)": "New admin password (at least 6 characters)", + "密码至少需要 6 位": "Password must be at least 6 characters", + "确认密码": "Confirm password", + "两次输入的密码不一致": "Passwords do not match", + "管理员密码已重置。": "Admin password has been reset.", + "按 Enter 返回菜单": "Press Enter to return to menu", + "选择要": "Select a container to ", + "的容器": "", + "选择无效": "Invalid selection", + "主机名": "Hostname", + "管理员用户": "Admin user", + "容器总数": "Total containers", + "切换语言": "Switch language", + "当前语言": "Current language", + "请选择语言": "Select language", + "语言已切换为": "Language switched to", + "保存语言失败": "Failed to save language", + "简体中文": "Simplified Chinese", + "重置失败": "Reset failed", + "停止 Web 面板失败": "Failed to stop web panel", + "Web 面板已停止,LXC 容器不会受影响。": "Web panel stopped. LXC containers are not affected.", + "启动 Web 面板失败": "Failed to start web panel", + "Web 面板已启动": "Web panel started", + "升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。": "The upgrade only replaces /usr/local/bin/clicd and keeps configuration, container data, and task records under /root/.clicd.", + "升级需要 root 权限。请使用: sudo clicd cli": "Upgrade requires root privileges. Use: sudo clicd cli", + "检查仓库": "Checking repository", + "检查 GitHub 最新版本失败": "Failed to check the latest GitHub version", + "GitHub Release 没有 tag_name,无法判断最新版本。": "GitHub Release has no tag_name, so the latest version cannot be determined.", + "最新版本": "Latest version", + "发布页面": "Release page", + "最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。": "The latest release does not contain clicd-linux-amd64.tar.gz, so automatic upgrade is unavailable.", + "当前已经是最新版本。": "The current version is already the latest.", + "是否仍然重新安装最新版本?输入 reinstall 继续": "Reinstall the latest version anyway? Type reinstall to continue", + "输入 upgrade 开始升级": "Type upgrade to start upgrade", + "已取消。": "Cancelled.", + "升级失败": "Upgrade failed", + "升级完成": "Upgrade completed", + "原有数据已保留,Web 服务已重启。": "Existing data has been kept and the web service has been restarted.", + "GitHub API 返回": "GitHub API returned", + "GitHub API 被限流,已切换到备用检查方式。": "GitHub API rate limit reached; switched to fallback check.", + "GitHub API 不可用,已切换到备用检查方式。": "GitHub API is unavailable; switched to fallback check.", + "GitHub releases/latest 返回": "GitHub releases/latest returned", + "无法从 GitHub releases/latest 跳转结果解析最新版本": "Unable to parse the latest version from the GitHub releases/latest redirect", + "正在下载升级包...": "Downloading upgrade package...", + "正在解压升级包...": "Extracting upgrade package...", + "解压失败": "Extraction failed", + "备份旧二进制失败": "Failed to back up old binary", + "旧版本已备份": "Old version backed up", + "正在替换二进制...": "Replacing binary...", + "停止 Web 服务失败,继续尝试替换": "Failed to stop web service; continuing replacement attempt", + "二进制已替换,但重启 Web 服务失败": "Binary was replaced, but restarting the web service failed", + "下载失败,HTTP": "Download failed, HTTP", + "升级包内未找到 clicd 二进制": "No clicd binary found in the upgrade package", + "将 /var/lib/lxc 里的容器导入 CLICD 配置。": "Import containers under /var/lib/lxc into CLICD configuration.", + "导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。": "After import, real LXC names are kept and both Web and CLI can manage the same containers.", + "导入失败": "Import failed", + "没有发现新的 ct-* 容器。": "No new ct-* containers found.", + "已导入": "Imported", + "个容器": "containers", + "将删除 CLICD 服务和 /usr/local/bin/clicd。": "This will remove the CLICD service and /usr/local/bin/clicd.", + "同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。": "It will also remove /root/.clicd, /var/lib/lxc, /var/lib/clicd, image caches, backups, temporary files, /swapfile, and CLICD network rules.", + "卸载需要 root 权限。": "Uninstall requires root privileges.", + "请运行: sudo clicd cli --no-web": "Run: sudo clicd cli --no-web", + "输入 uninstall 继续卸载": "Type uninstall to continue uninstalling", + "CLICD 已卸载。": "CLICD has been uninstalled.", + "服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。": "Service, binary, configuration, containers/VMs, local images, cache, backups, temporary files, and CLICD network rules have been removed.", + "检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。": "Non-CLICD VMs are still using the libvirt default network, so default/virbr0 has been kept.", + "Web 面板重载跳过": "Web panel reload skipped", + "Web 面板已重载并应用配置变更。": "Web panel reloaded and configuration changes applied.", + "读取容器状态失败": "Failed to read container status", + "CLICD 版本": "CLICD version", + "Web 端口": "Web port", + "LXC 版本": "LXC version", + "暂无可用容器": "No available containers", + "忽略无效端口": "Ignoring invalid port", + "?": "? ", + "。": ". ", + ",": ", ", + ":": ": ", +} + // Run starts the CLI interface. func Run() { reader := bufio.NewReader(os.Stdin) for { if _, err := config.InitConfig(); err != nil { - fmt.Printf("重新加载配置失败: %v\n", err) + cliPrintf("重新加载配置失败: %v\n", err) waitEnter(reader) } + refreshCLILanguage() clearScreen() printMenu() - fmt.Print("\n请选择操作 [1-12,0/q]: ") + cliPrint("\n请选择操作 [1-12,l,0/q]: ") input, _ := reader.ReadString('\n') input = strings.TrimSpace(input) @@ -94,11 +247,15 @@ func Run() { clearScreen() cliShowInfo() waitEnter(reader) + case "l", "lang", "language": + clearScreen() + cliSwitchLanguage(reader) + waitEnter(reader) case "q", "exit", "quit": - fmt.Println("再见") + cliPrintln("再见") return default: - fmt.Println("无效选择") + cliPrintln("无效选择") } } } @@ -108,49 +265,86 @@ func printMenu() { if isWebPanelRunning() { webStatus = "停止" } - fmt.Println() - fmt.Println(" ==========================================") - fmt.Println(" CLICD - LXC 容器管理器") - fmt.Println(" ==========================================") - fmt.Println() - fmt.Printf(" Web 面板: %s (端口 %d)\n", func() string { + cliPrintln("") + cliPrintln(" ==========================================") + cliPrintln(" CLICD - LXC 容器管理器") + cliPrintln(" ==========================================") + cliPrintln("") + cliPrintf(" Web 面板: %s (端口 %d)\n", func() string { if isWebPanelRunning() { return "运行中" } return "已停止" }(), config.AppConfig.Port) - fmt.Printf(" 当前版本: %s\n", version.Current()) - fmt.Println() - fmt.Println(" 1. 查看容器列表") - fmt.Println(" 2. 创建容器") - fmt.Println(" 3. 开机容器") - fmt.Println(" 4. 关机容器") - fmt.Println(" 5. 重启容器") - fmt.Println(" 6. 删除容器") - fmt.Println(" 7. 重装容器系统") - fmt.Println(" 8. 重置 Web 管理员密码") - fmt.Printf(" 9. %s Web 面板\n", webStatus) - fmt.Println(" 10. 导入现有 LXC 容器") - fmt.Println(" 11. 检查并升级 CLICD") - fmt.Println(" 12. 卸载 CLICD") - fmt.Println(" 0. 系统信息") - fmt.Println(" q. 退出") + cliPrintf(" 当前版本: %s\n", version.Current()) + cliPrintln("") + cliPrintln(" 1. 查看容器列表") + cliPrintln(" 2. 创建容器") + cliPrintln(" 3. 开机容器") + cliPrintln(" 4. 关机容器") + cliPrintln(" 5. 重启容器") + cliPrintln(" 6. 删除容器") + cliPrintln(" 7. 重装容器系统") + cliPrintln(" 8. 重置 Web 管理员密码") + cliPrintf(" 9. %s Web 面板\n", webStatus) + cliPrintln(" 10. 导入现有 LXC 容器") + cliPrintln(" 11. 检查并升级 CLICD") + cliPrintln(" 12. 卸载 CLICD") + cliPrintln(" 0. 系统信息") + cliPrintln(" l. 切换语言") + cliPrintln(" q. 退出") +} + +func cliSwitchLanguage(reader *bufio.Reader) { + cliPrintf("\n--- %s ---\n", cliT("切换语言")) + cliPrintf("%s: %s\n", cliT("当前语言"), cliLanguageLabel(config.NormalizeLanguage(config.AppConfig.Language))) + cliPrintln(" 1. 简体中文") + cliPrintln(" 2. English") + choice := promptString(reader, "请选择语言 [1/2]", func() string { + if config.NormalizeLanguage(config.AppConfig.Language) == "en" { + return "2" + } + return "1" + }()) + + next := "zh" + switch strings.ToLower(strings.TrimSpace(choice)) { + case "2", "en", "english": + next = "en" + case "1", "zh", "cn", "chinese": + next = "zh" + default: + cliPrintln("无效选择") + return + } + + config.AppConfig.Language = next + if err := config.SaveConfig(); err != nil { + cliPrintf("保存语言失败: %v\n", err) + return + } + _ = os.Setenv("CLICD_LANG", next) + refreshCLILanguage() + cliPrintf("%s: %s\n", cliT("语言已切换为"), cliLanguageLabel(next)) + if isWebPanelRunning() { + restartWebPanelForConfigChange() + } } func cliListContainers() { containers, err := manager.ListContainers() if err != nil { - fmt.Printf("获取容器列表失败: %v\n", err) + cliPrintf("获取容器列表失败: %v\n", err) return } if len(containers) == 0 { - fmt.Println("\n暂无容器") + cliPrintln("\n暂无容器") return } fmt.Println() - fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", "名称", "状态", "镜像", "vCPU", "内存(MB)", "磁盘(GB)", "SSH") + fmt.Printf("%-18s %-10s %-18s %-6s %-10s %-10s %-16s\n", cliT("名称"), cliT("状态"), cliT("镜像"), "vCPU", cliT("内存(MB)"), cliT("磁盘(GB)"), "SSH") fmt.Println(strings.Repeat("-", 94)) for _, c := range containers { ssh := "-" @@ -163,23 +357,23 @@ func cliListContainers() { } func cliCreateContainer(reader *bufio.Reader) { - fmt.Println("\n--- 创建容器 ---") + cliPrintln("\n--- 创建容器 ---") name := promptString(reader, "容器名称", "") if name == "" { - fmt.Println("容器名称不能为空") + cliPrintln("容器名称不能为空") return } templates := lxc.GetTemplates() - fmt.Println("\n可用镜像:") + cliPrintln("\n可用镜像:") for i, template := range templates { fmt.Printf(" %d. %s\n", i+1, template.Name) } tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1) if tmplIdx < 1 || tmplIdx > len(templates) { - fmt.Println("镜像选择无效") + cliPrintln("镜像选择无效") return } @@ -195,16 +389,16 @@ func cliCreateContainer(reader *bufio.Reader) { ExtraPorts: promptPortList(reader, "额外 NAT 端口,多个用逗号分隔"), } - fmt.Printf("\n正在创建容器 %s ...\n", name) + cliPrintf("\n正在创建容器 %s ...\n", name) if err := manager.CreateContainer(cfg); err != nil { - fmt.Printf("创建失败: %v\n", err) + cliPrintf("创建失败: %v\n", err) return } container := config.FindContainerByName(name) - fmt.Printf("容器 %s 创建成功\n", name) + cliPrintf("容器 %s 创建成功\n", name) if container != nil { - fmt.Print(formatSSHAccess(container.SSHPort)) + cliPrint(formatSSHAccess(container.SSHPort)) } restartWebPanelForConfigChange() } @@ -222,10 +416,10 @@ func cliStartContainer(reader *bufio.Reader) { return } if err := manager.StartContainer(id); err != nil { - fmt.Printf("开机失败: %v\n", err) + cliPrintf("开机失败: %v\n", err) return } - fmt.Printf("容器 %s 已开机\n", name) + cliPrintf("容器 %s 已开机\n", name) } func cliStopContainer(reader *bufio.Reader) { @@ -234,10 +428,10 @@ func cliStopContainer(reader *bufio.Reader) { return } if err := manager.StopContainer(id); err != nil { - fmt.Printf("关机失败: %v\n", err) + cliPrintf("关机失败: %v\n", err) return } - fmt.Printf("容器 %s 已关机\n", name) + cliPrintf("容器 %s 已关机\n", name) } func cliRestartContainer(reader *bufio.Reader) { @@ -246,10 +440,10 @@ func cliRestartContainer(reader *bufio.Reader) { return } if err := manager.RestartContainer(id); err != nil { - fmt.Printf("重启失败: %v\n", err) + cliPrintf("重启失败: %v\n", err) return } - fmt.Printf("容器 %s 已重启\n", name) + cliPrintf("容器 %s 已重启\n", name) } func cliDeleteContainer(reader *bufio.Reader) { @@ -259,14 +453,14 @@ func cliDeleteContainer(reader *bufio.Reader) { } confirm := promptString(reader, fmt.Sprintf("确认删除容器 %s?输入 yes 继续", name), "no") if strings.ToLower(confirm) != "yes" { - fmt.Println("已取消") + cliPrintln("已取消") return } if err := manager.DestroyContainer(id); err != nil { - fmt.Printf("删除失败: %v\n", err) + cliPrintf("删除失败: %v\n", err) return } - fmt.Printf("容器 %s 已删除\n", name) + cliPrintf("容器 %s 已删除\n", name) restartWebPanelForConfigChange() } @@ -277,66 +471,66 @@ func cliReinstallContainer(reader *bufio.Reader) { } templates := lxc.GetTemplates() - fmt.Println("\n可用镜像:") + cliPrintln("\n可用镜像:") for i, template := range templates { fmt.Printf(" %d. %s\n", i+1, template.Name) } tmplIdx := promptInt(reader, fmt.Sprintf("镜像 [1-%d]", len(templates)), 1) if tmplIdx < 1 || tmplIdx > len(templates) { - fmt.Println("镜像选择无效") + cliPrintln("镜像选择无效") return } confirm := promptString(reader, fmt.Sprintf("确认重装容器 %s?输入 yes 继续", name), "no") if strings.ToLower(confirm) != "yes" { - fmt.Println("已取消") + cliPrintln("已取消") return } if err := manager.ReinstallContainer(id, templates[tmplIdx-1].ID); err != nil { - fmt.Printf("重装失败: %v\n", err) + cliPrintf("重装失败: %v\n", err) return } - fmt.Printf("容器 %s 已重装\n", name) + cliPrintf("容器 %s 已重装\n", name) restartWebPanelForConfigChange() } func cliResetPassword(reader *bufio.Reader) { newPass := promptString(reader, "新的管理员密码(至少 6 位)", "") if len(newPass) < 6 { - fmt.Println("密码至少需要 6 位") + cliPrintln("密码至少需要 6 位") return } confirm := promptString(reader, "确认密码", "") if newPass != confirm { - fmt.Println("两次输入的密码不一致") + cliPrintln("两次输入的密码不一致") return } if err := config.ResetAdminPassword(newPass); err != nil { - fmt.Printf("重置失败: %v\n", err) + cliPrintf("重置失败: %v\n", err) return } - fmt.Println("管理员密码已重置。") + cliPrintln("管理员密码已重置。") restartWebPanelForConfigChange() } func cliToggleWebPanel() { if isWebPanelRunning() { if err := stopService("clicd"); err != nil { - fmt.Printf("停止 Web 面板失败: %v\n", err) + cliPrintf("停止 Web 面板失败: %v\n", err) return } - fmt.Println("Web 面板已停止,LXC 容器不会受影响。") + cliPrintln("Web 面板已停止,LXC 容器不会受影响。") return } if err := startService("clicd"); err != nil { - fmt.Printf("启动 Web 面板失败: %v\n", err) + cliPrintf("启动 Web 面板失败: %v\n", err) return } - fmt.Println("Web 面板已启动") + cliPrintln("Web 面板已启动") } type githubRelease struct { @@ -350,11 +544,11 @@ type githubRelease struct { } func cliUpgradeSystem(reader *bufio.Reader) { - fmt.Println("\n--- 检查并升级 CLICD ---") - fmt.Println("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。") + cliPrintln("\n--- 检查并升级 CLICD ---") + cliPrintln("升级只会替换 /usr/local/bin/clicd,并保留 /root/.clicd 里的配置、容器数据和任务记录。") if os.Geteuid() != 0 { - fmt.Println("升级需要 root 权限。请使用: sudo clicd cli") + cliPrintln("升级需要 root 权限。请使用: sudo clicd cli") return } @@ -363,51 +557,51 @@ func cliUpgradeSystem(reader *bufio.Reader) { repo = version.Repo } current := version.Current() - fmt.Printf("当前版本: %s\n", current) - fmt.Printf("检查仓库: https://github.com/%s\n", repo) + cliPrintf("当前版本: %s\n", current) + cliPrintf("检查仓库: https://github.com/%s\n", repo) release, err := fetchLatestRelease(repo) if err != nil { - fmt.Printf("检查 GitHub 最新版本失败: %v\n", err) + cliPrintf("检查 GitHub 最新版本失败: %v\n", err) return } latest := strings.TrimSpace(release.TagName) if latest == "" { - fmt.Println("GitHub Release 没有 tag_name,无法判断最新版本。") + cliPrintln("GitHub Release 没有 tag_name,无法判断最新版本。") return } - fmt.Printf("最新版本: %s\n", latest) + cliPrintf("最新版本: %s\n", latest) if release.HTMLURL != "" { - fmt.Printf("发布页面: %s\n", release.HTMLURL) + cliPrintf("发布页面: %s\n", release.HTMLURL) } assetURL := findReleaseAsset(release, "clicd-linux-amd64.tar.gz") if assetURL == "" { - fmt.Println("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。") + cliPrintln("最新 Release 没有找到 clicd-linux-amd64.tar.gz,无法自动升级。") return } if sameVersion(current, latest) { - fmt.Println("当前已经是最新版本。") + cliPrintln("当前已经是最新版本。") confirm := promptString(reader, "是否仍然重新安装最新版本?输入 reinstall 继续", "no") if strings.ToLower(confirm) != "reinstall" { - fmt.Println("已取消。") + cliPrintln("已取消。") return } } else { confirm := promptString(reader, "输入 upgrade 开始升级", "no") if strings.ToLower(confirm) != "upgrade" { - fmt.Println("已取消。") + cliPrintln("已取消。") return } } if err := upgradeFromReleaseAsset(assetURL, latest); err != nil { - fmt.Printf("升级失败: %v\n", err) + cliPrintf("升级失败: %v\n", err) return } - fmt.Printf("升级完成: %s -> %s\n", current, latest) - fmt.Println("原有数据已保留,Web 服务已重启。") + cliPrintf("升级完成: %s -> %s\n", current, latest) + cliPrintln("原有数据已保留,Web 服务已重启。") } func fetchLatestRelease(repo string) (*githubRelease, error) { @@ -434,9 +628,9 @@ func fetchLatestRelease(repo string) (*githubRelease, error) { apiErr := fmt.Errorf("GitHub API 返回 %s: %s", resp.Status, strings.TrimSpace(string(body))) if fallback, fallbackErr := fetchLatestReleaseFallback(repo); fallbackErr == nil { if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { - fmt.Println("GitHub API 被限流,已切换到备用检查方式。") + cliPrintln("GitHub API 被限流,已切换到备用检查方式。") } else { - fmt.Println("GitHub API 不可用,已切换到备用检查方式。") + cliPrintln("GitHub API 不可用,已切换到备用检查方式。") } return fallback, nil } @@ -530,12 +724,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error { defer os.RemoveAll(tmpDir) archivePath := filepath.Join(tmpDir, "clicd-linux-amd64.tar.gz") - fmt.Println("正在下载升级包...") + cliPrintln("正在下载升级包...") if err := downloadFile(assetURL, archivePath); err != nil { return err } - fmt.Println("正在解压升级包...") + cliPrintln("正在解压升级包...") if out, err := exec.Command("tar", "-xzf", archivePath, "-C", tmpDir).CombinedOutput(); err != nil { return fmt.Errorf("解压失败: %v, output: %s", err, string(out)) } @@ -555,12 +749,12 @@ func upgradeFromReleaseAsset(assetURL, latest string) error { if err != nil { return fmt.Errorf("备份旧二进制失败: %w", err) } - fmt.Printf("旧版本已备份: %s\n", backupPath) + cliPrintf("旧版本已备份: %s\n", backupPath) } - fmt.Println("正在替换二进制...") + cliPrintln("正在替换二进制...") if err := stopService("clicd"); err != nil { - fmt.Printf("停止 Web 服务失败,继续尝试替换: %v\n", err) + cliPrintf("停止 Web 服务失败,继续尝试替换: %v\n", err) } tmpBin := clicdNewBinaryPath if err := copyFileToUpgradeTemp(newBinary, 0755); err != nil { @@ -715,21 +909,21 @@ func isWebPanelRunning() bool { } func cliImportExistingContainers() { - fmt.Println("\n--- 导入现有 LXC 容器 ---") - fmt.Println("将 /var/lib/lxc 里的容器导入 CLICD 配置。") - fmt.Println("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。") + cliPrintln("\n--- 导入现有 LXC 容器 ---") + cliPrintln("将 /var/lib/lxc 里的容器导入 CLICD 配置。") + cliPrintln("导入后会保留真实 LXC 名称,Web 和 CLI 都能管理同一个容器。") imported, err := manager.ImportExistingClicdContainers() if err != nil { - fmt.Printf("导入失败: %v\n", err) + cliPrintf("导入失败: %v\n", err) return } if len(imported) == 0 { - fmt.Println("没有发现新的 ct-* 容器。") + cliPrintln("没有发现新的 ct-* 容器。") return } - fmt.Printf("已导入 %d 个容器:\n", len(imported)) + cliPrintf("已导入 %d 个容器:\n", len(imported)) for _, c := range imported { fmt.Printf(" [%d] %s [%s]\n", c.ID, c.Name, c.Status) } @@ -737,19 +931,19 @@ func cliImportExistingContainers() { } func cliUninstall(reader *bufio.Reader) { - fmt.Println("\n--- 卸载 CLICD ---") - fmt.Println("将删除 CLICD 服务和 /usr/local/bin/clicd。") - fmt.Println("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。") + cliPrintln("\n--- 卸载 CLICD ---") + cliPrintln("将删除 CLICD 服务和 /usr/local/bin/clicd。") + cliPrintln("同时会删除 /root/.clicd、/var/lib/lxc、/var/lib/clicd、镜像缓存、备份、临时文件、/swapfile 和 CLICD 网络规则。") if os.Geteuid() != 0 { - fmt.Println("卸载需要 root 权限。") - fmt.Println("请运行: sudo clicd cli --no-web") + cliPrintln("卸载需要 root 权限。") + cliPrintln("请运行: sudo clicd cli --no-web") return } confirm := promptString(reader, "输入 uninstall 继续卸载", "no") if strings.ToLower(confirm) != "uninstall" { - fmt.Println("已取消") + cliPrintln("已取消") return } @@ -776,8 +970,8 @@ func cliUninstall(reader *bufio.Reader) { reloadSysctl() fmt.Println() - fmt.Println("CLICD 已卸载。") - fmt.Println("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。") + cliPrintln("CLICD 已卸载。") + cliPrintln("服务、二进制、配置、容器/虚拟机、本地镜像、缓存、备份、临时文件和 CLICD 网络规则均已删除。") } func destroyAllLXCContainers() { @@ -847,7 +1041,7 @@ func removeCLICDLibvirtDefaultNetwork() { return } if libvirtDefaultUsedByNonCLICDDomain() { - fmt.Println("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。") + cliPrintln("检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default/virbr0。") return } fmt.Println("Removing CLICD-created libvirt default network...") @@ -1242,10 +1436,10 @@ func shellQuote(value string) string { func restartWebPanelForConfigChange() { if err := restartService("clicd"); err != nil { - fmt.Printf("Web 面板重载跳过: %v\n", err) + cliPrintf("Web 面板重载跳过: %v\n", err) return } - fmt.Println("Web 面板已重载并应用配置变更。") + cliPrintln("Web 面板已重载并应用配置变更。") } func stopService(name string) error { @@ -1281,7 +1475,7 @@ func restartService(name string) error { func cliShowInfo() { containers, err := manager.ListContainers() if err != nil { - fmt.Printf("读取容器状态失败: %v\n", err) + cliPrintf("读取容器状态失败: %v\n", err) } total := len(containers) @@ -1292,44 +1486,44 @@ func cliShowInfo() { } } - fmt.Println("\n--- 系统信息 ---") - fmt.Printf("CLICD 版本: %s\n", version.Current()) - fmt.Printf("Web 端口: %d\n", config.AppConfig.Port) - fmt.Printf("管理员用户: %s\n", config.AppConfig.AdminUser) - fmt.Printf("容器总数: %d\n", total) - fmt.Printf("运行中: %d\n", running) - fmt.Printf("已停止: %d\n", total-running) + cliPrintln("\n--- 系统信息 ---") + cliPrintf("CLICD 版本: %s\n", version.Current()) + cliPrintf("Web 端口: %d\n", config.AppConfig.Port) + cliPrintf("管理员用户: %s\n", config.AppConfig.AdminUser) + cliPrintf("容器总数: %d\n", total) + cliPrintf("运行中: %d\n", running) + cliPrintf("已停止: %d\n", total-running) if hostname, err := os.Hostname(); err == nil { - fmt.Printf("主机名: %s\n", hostname) + cliPrintf("主机名: %s\n", hostname) } cmd := exec.Command("lxc-info", "--version") output, err := cmd.Output() if err == nil { - fmt.Printf("LXC 版本: %s", string(output)) + cliPrintf("LXC 版本: %s", string(output)) } } func selectContainer(reader *bufio.Reader, action string) (int, string) { containers, err := manager.ListContainers() if err != nil { - fmt.Printf("获取容器列表失败: %v\n", err) + cliPrintf("获取容器列表失败: %v\n", err) return 0, "" } if len(containers) == 0 { - fmt.Println("暂无可用容器") + cliPrintln("暂无可用容器") return 0, "" } - fmt.Printf("\n--- 选择要%s的容器 ---\n", action) + cliPrintf("\n--- 选择要%s的容器 ---\n", cliT(action)) for i, container := range containers { fmt.Printf(" %d. [%d] %s [%s]\n", i+1, container.ID, container.Name, container.Status) } idx := promptInt(reader, "容器", 0) if idx < 1 || idx > len(containers) { - fmt.Println("选择无效") + cliPrintln("选择无效") return 0, "" } @@ -1338,6 +1532,7 @@ func selectContainer(reader *bufio.Reader, action string) (int, string) { } func promptString(reader *bufio.Reader, label string, fallback string) string { + label = cliT(label) if fallback == "" { fmt.Printf("%s: ", label) } else { @@ -1375,7 +1570,7 @@ func clearScreen() { } func waitEnter(reader *bufio.Reader) { - fmt.Print("\n按 Enter 返回菜单...") + cliPrint("\n按 Enter 返回菜单...") reader.ReadString('\n') } @@ -1389,10 +1584,95 @@ func promptPortList(reader *bufio.Reader, label string) []int { for _, part := range strings.Split(input, ",") { value, err := strconv.Atoi(strings.TrimSpace(part)) if err != nil || value <= 0 || value > 65535 { - fmt.Printf("忽略无效端口: %s\n", strings.TrimSpace(part)) + cliPrintf("忽略无效端口: %s\n", strings.TrimSpace(part)) continue } ports = append(ports, value) } return ports } + +func detectCLIEnglish() bool { + lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG"))) + if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") { + return true + } + if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") { + return false + } + if config.AppConfig != nil { + return config.NormalizeLanguage(config.AppConfig.Language) == "en" + } + env := strings.ToLower(os.Getenv("LC_ALL") + " " + os.Getenv("LC_MESSAGES") + " " + os.Getenv("LANG")) + return strings.Contains(env, "en_") || strings.Contains(env, "en-") || strings.Contains(env, "english") +} + +func refreshCLILanguage() { + cliEnglish = detectCLIEnglish() +} + +func cliLanguageLabel(language string) string { + if config.NormalizeLanguage(language) == "en" { + return "English" + } + return cliT("简体中文") +} + +func cliT(text string) string { + if !cliEnglish { + return text + } + translated := text + keys := make([]string, 0, len(cliTranslations)) + for zh := range cliTranslations { + keys = append(keys, zh) + } + sort.Slice(keys, func(i, j int) bool { + if len(keys[i]) == len(keys[j]) { + return keys[i] < keys[j] + } + return len(keys[i]) > len(keys[j]) + }) + for _, zh := range keys { + en := cliTranslations[zh] + translated = strings.ReplaceAll(translated, zh, en) + } + return translated +} + +func cliPrint(args ...interface{}) { + if cliEnglish { + for i, arg := range args { + if s, ok := arg.(string); ok { + args[i] = cliT(s) + } + } + } + fmt.Print(args...) +} + +func cliPrintln(args ...interface{}) { + if cliEnglish { + for i, arg := range args { + if s, ok := arg.(string); ok { + args[i] = cliT(s) + } + } + } + fmt.Println(args...) +} + +func cliPrintf(format string, args ...interface{}) { + if cliEnglish { + for i, arg := range args { + if s, ok := arg.(string); ok { + args[i] = cliT(s) + continue + } + if err, ok := arg.(error); ok { + args[i] = cliT(err.Error()) + } + } + } + fmt.Printf(cliT(format), args...) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1560de8..3130f8b 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -243,6 +243,7 @@ type ClicdConfig struct { EnabledImages []string `json:"enabled_images"` Snapshots []Snapshot `json:"snapshots"` SecurityAutoShutdown bool `json:"security_auto_shutdown"` + Language string `json:"language"` SSL SSLConfig `json:"ssl"` SSLCertificates map[string]SSLConfig `json:"ssl_certificates"` } @@ -454,12 +455,29 @@ func normalizeConfigDefaults(dataDir string) bool { AppConfig.EnabledImages = make([]string, 0) changed = true } + if AppConfig.Language == "" { + AppConfig.Language = "zh" + changed = true + } + if AppConfig.Language != "zh" && AppConfig.Language != "en" { + AppConfig.Language = "zh" + changed = true + } if normalizeSSLDefaults() { changed = true } return changed } +func NormalizeLanguage(language string) string { + switch strings.ToLower(strings.TrimSpace(language)) { + case "en", "en-us", "en_us", "english": + return "en" + default: + return "zh" + } +} + func normalizeSSLDefaults() bool { changed := false previousMode := AppConfig.SSL.Mode diff --git a/backend/internal/config/store_sqlite.go b/backend/internal/config/store_sqlite.go index ae01ae8..d48fe42 100644 --- a/backend/internal/config/store_sqlite.go +++ b/backend/internal/config/store_sqlite.go @@ -373,6 +373,7 @@ func loadConfigFromDB() (*ClicdConfig, bool, error) { NextSSHPort: atoi(meta["next_ssh_port"]), SetupComplete: atob(meta["setup_complete"]), SecurityAutoShutdown: atob(meta["security_auto_shutdown"]), + Language: meta["language"], } if raw := strings.TrimSpace(meta["ssl"]); raw != "" { _ = json.Unmarshal([]byte(raw), &cfg.SSL) @@ -485,6 +486,7 @@ func saveMeta(tx *sql.Tx) error { "next_ssh_port": strconv.Itoa(AppConfig.NextSSHPort), "setup_complete": btoa(AppConfig.SetupComplete), "security_auto_shutdown": btoa(AppConfig.SecurityAutoShutdown), + "language": NormalizeLanguage(AppConfig.Language), "ssl": string(sslJSON), "ssl_certificates": string(sslCertificatesJSON), "schema_version": "1", diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index e2d18dd..d1b0a06 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -72,6 +72,7 @@ func isLoopbackHost(host string) bool { func setupRoutes(mux *http.ServeMux) { // API routes mux.HandleFunc("/api/login", corsMiddleware(api.HandleLogin)) + mux.HandleFunc("/api/language", corsMiddleware(api.HandleLanguage)) mux.HandleFunc("/api/check-auth", corsMiddleware(api.AuthMiddleware(api.HandleCheckAuth))) mux.HandleFunc("/api/change-password", corsMiddleware(api.AdminMiddleware(api.HandleAdminPasswordChange))) mux.HandleFunc("/api/change-username", corsMiddleware(api.AdminMiddleware(api.HandleAdminUsernameChange))) @@ -119,6 +120,7 @@ func setupRoutes(mux *http.ServeMux) { // Versioned external API routes mux.HandleFunc("/api/v1/dashboard", corsMiddleware(api.AuthMiddleware(api.HandleDashboard))) + mux.HandleFunc("/api/v1/language", corsMiddleware(api.HandleLanguage)) mux.HandleFunc("/api/v1/containers", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainers)))) mux.HandleFunc("/api/v1/containers/list", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleContainerListAlias)))) mux.HandleFunc("/api/v1/containers/", corsMiddleware(api.AuthMiddleware(api.SubUserMiddleware(api.HandleSingleContainer)))) diff --git a/backend/main.go b/backend/main.go index 5bebfb3..317ea59 100644 --- a/backend/main.go +++ b/backend/main.go @@ -107,8 +107,33 @@ func isWebPanelSystemdRunning() bool { func startWebPanelSystemd() { cmd := exec.Command("systemctl", "start", "clicd") if err := cmd.Run(); err != nil { - fmt.Fprintf(os.Stderr, "警告: 自动启动 Web 面板失败: %v\n", err) + fmt.Fprintf(os.Stderr, "%s: %v\n", mainT("警告: 自动启动 Web 面板失败"), err) } else { - fmt.Println("Web 面板已自动启动") + fmt.Println(mainT("Web 面板已自动启动")) } } + +func mainT(text string) string { + if !mainEnglish() { + return text + } + switch text { + case "警告: 自动启动 Web 面板失败": + return "Warning: failed to auto-start web panel" + case "Web 面板已自动启动": + return "Web panel auto-started" + default: + return text + } +} + +func mainEnglish() bool { + lang := strings.ToLower(strings.TrimSpace(os.Getenv("CLICD_LANG"))) + if lang == "en" || strings.HasPrefix(lang, "en_") || strings.HasPrefix(lang, "en-") { + return true + } + if lang == "zh" || strings.HasPrefix(lang, "zh_") || strings.HasPrefix(lang, "zh-") { + return false + } + return config.AppConfig != nil && config.NormalizeLanguage(config.AppConfig.Language) == "en" +} diff --git a/frontend/src/components/AutoTranslate.tsx b/frontend/src/components/AutoTranslate.tsx new file mode 100644 index 0000000..5ae46ef --- /dev/null +++ b/frontend/src/components/AutoTranslate.tsx @@ -0,0 +1,182 @@ +import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' +import { useLanguage } from '../contexts/LanguageContext' +import { shouldTranslateText, translateText } from '../utils/i18n' + +const translatedTitleAttr = 'data-i18n-title-original' +const translatedPlaceholderAttr = 'data-i18n-placeholder-original' +const translatedAriaLabelAttr = 'data-i18n-aria-label-original' + +const attributeNames = ['title', 'placeholder', 'aria-label'] as const +const translatedTextNodes = new Set() +const textOriginals = new WeakMap() +const wholeTextSelector = 'button,a,span,label,option,th,td,p,h1,h2,h3,h4,small' + +export default function AutoTranslate() { + const { language } = useLanguage() + const location = useLocation() + + useEffect(() => { + if (language === 'zh') { + restoreTranslatedNodes(document.body) + return + } + + translateNode(document.body) + + const pending = new Set() + let scheduled = false + const flush = () => { + scheduled = false + const nodes = Array.from(pending) + pending.clear() + for (const node of nodes) { + if (node.isConnected) translateNode(node) + } + } + const schedule = (node: Node) => { + pending.add(node) + if (scheduled) return + scheduled = true + window.requestAnimationFrame(flush) + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + mutation.addedNodes.forEach(schedule) + } else { + schedule(mutation.target) + } + } + }) + observer.observe(document.body, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: [...attributeNames], + }) + return () => observer.disconnect() + }, [language, location.pathname, location.search]) + + return null +} + +function translateNode(root: Node) { + if (root.nodeType === Node.TEXT_NODE) { + translateTextNode(root as Text) + return + } + if (!(root instanceof Element)) return + if (shouldSkipElement(root)) return + + translateWholeTextElement(root) + root.querySelectorAll(wholeTextSelector).forEach(translateWholeTextElement) + translateElementAttributes(root) + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node.textContent || !shouldTranslateText(node.textContent)) return NodeFilter.FILTER_REJECT + const parent = node.parentElement + if (!parent || shouldSkipElement(parent)) { + return NodeFilter.FILTER_REJECT + } + return NodeFilter.FILTER_ACCEPT + }, + }) + + const nodes: Text[] = [] + while (walker.nextNode()) nodes.push(walker.currentNode as Text) + for (const node of nodes) translateTextNode(node) + root.querySelectorAll('[title], [placeholder], [aria-label]').forEach(translateElementAttributes) +} + +function translateTextNode(node: Text) { + const original = node.textContent || '' + if (!shouldTranslateText(original)) return + const parent = node.parentElement + if (!parent || shouldSkipElement(parent)) return + const translated = translateText(original) + if (translated === original) return + textOriginals.set(node, original) + translatedTextNodes.add(node) + node.textContent = translated +} + +function translateWholeTextElement(el: Element) { + if (!(el instanceof HTMLElement) || shouldSkipElement(el) || !isSimpleTextElement(el)) return + const original = el.textContent || '' + if (!shouldTranslateText(original)) return + const translated = translateText(original) + if (translated === original) return + + const textNodes = directTextNodes(el) + if (textNodes.length === 0) return + textNodes.forEach((node, index) => { + textOriginals.set(node, node.textContent || '') + translatedTextNodes.add(node) + node.textContent = index === 0 ? translated : '' + }) +} + +function directTextNodes(el: HTMLElement) { + return Array.from(el.childNodes).filter((node): node is Text => node.nodeType === Node.TEXT_NODE) +} + +function translateElementAttributes(el: Element) { + if (!(el instanceof HTMLElement)) return + translateAttribute(el, 'title', translatedTitleAttr) + translateAttribute(el, 'placeholder', translatedPlaceholderAttr) + translateAttribute(el, 'aria-label', translatedAriaLabelAttr) +} + +function restoreTranslatedNodes(root: ParentNode) { + for (const node of Array.from(translatedTextNodes)) { + if (!node.isConnected) { + translatedTextNodes.delete(node) + continue + } + if (root instanceof Document || root.contains(node)) { + node.textContent = textOriginals.get(node) || node.textContent + translatedTextNodes.delete(node) + } + } + root.querySelectorAll(`[${translatedTitleAttr}]`).forEach((el) => { + el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '') + el.removeAttribute(translatedTitleAttr) + }) + root.querySelectorAll(`[${translatedPlaceholderAttr}]`).forEach((el) => { + el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '') + el.removeAttribute(translatedPlaceholderAttr) + }) + root.querySelectorAll(`[${translatedAriaLabelAttr}]`).forEach((el) => { + el.setAttribute('aria-label', el.getAttribute(translatedAriaLabelAttr) || '') + el.removeAttribute(translatedAriaLabelAttr) + }) +} + +function translateAttribute(el: HTMLElement, attr: 'title' | 'placeholder' | 'aria-label', originalAttr: string) { + const storedOriginal = el.getAttribute(originalAttr) + const original = storedOriginal || el.getAttribute(attr) || '' + if (!shouldTranslateText(original)) return + const translated = translateText(original) + if (translated === original) return + if (!storedOriginal) { + el.setAttribute(originalAttr, original) + } + if (el.getAttribute(attr) !== translated) { + el.setAttribute(attr, translated) + } +} + +function shouldSkipElement(el: Element) { + return !!el.closest('script, style, code, pre, textarea, [data-no-translate]') +} + +function isSimpleTextElement(el: HTMLElement) { + if (!el.matches(wholeTextSelector)) return false + if (el.querySelector('input, textarea, select, button, table, pre, code, canvas, iframe')) return false + const textNodes = directTextNodes(el) + if (textNodes.length === 0) return false + return Array.from(el.children).every((child) => child.tagName.toLowerCase() === 'svg') +} diff --git a/frontend/src/components/BrowserDialogTranslator.tsx b/frontend/src/components/BrowserDialogTranslator.tsx new file mode 100644 index 0000000..6c05855 --- /dev/null +++ b/frontend/src/components/BrowserDialogTranslator.tsx @@ -0,0 +1,19 @@ +import { useEffect } from 'react' +import { useLanguage } from '../contexts/LanguageContext' + +export default function BrowserDialogTranslator() { + const { t } = useLanguage() + + useEffect(() => { + const originalAlert = window.alert + const originalConfirm = window.confirm + window.alert = (message?: unknown) => originalAlert(t(String(message ?? ''))) + window.confirm = (message?: string) => originalConfirm(t(String(message ?? ''))) + return () => { + window.alert = originalAlert + window.confirm = originalConfirm + } + }, [t]) + + return null +} diff --git a/frontend/src/components/Dialog.tsx b/frontend/src/components/Dialog.tsx index f5a6f37..c7d3cee 100644 --- a/frontend/src/components/Dialog.tsx +++ b/frontend/src/components/Dialog.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, createContext, useContext, ReactNode } from 'react' import { AlertTriangle, CheckCircle, X } from 'lucide-react' +import { useLanguage } from '../contexts/LanguageContext' type DialogType = 'confirm' | 'alert' @@ -20,6 +21,7 @@ const DialogContext = createContext(undefined) export function DialogProvider({ children }: { children: ReactNode }) { const [dialog, setDialog] = useState({ open: false, type: 'alert', title: '', message: '' }) + const { t } = useLanguage() const confirm = useCallback((title: string, message: string) => { return new Promise((resolve) => { @@ -50,7 +52,7 @@ export function DialogProvider({ children }: { children: ReactNode }) { }`}> {dialog.type === 'confirm' ? : } -

{dialog.title}

+

{t(dialog.title)}

{dialog.type === 'alert' && ( )} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c50ac87..4e903de 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,12 +1,16 @@ import { Outlet } from 'react-router-dom' import Sidebar from './Sidebar' import { useState } from 'react' +import AutoTranslate from './AutoTranslate' +import BrowserDialogTranslator from './BrowserDialogTranslator' export default function Layout() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) return (
+ + setSidebarCollapsed(!sidebarCollapsed)} />
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e2a5f8f..a5cbea8 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -19,6 +19,7 @@ import { UserCog, } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' +import { useLanguage } from '../contexts/LanguageContext' import { useTheme } from '../contexts/ThemeContext' import { getVersion } from '../services/api' import AppIcon from './AppIcon' @@ -45,11 +46,23 @@ function GitHubIcon({ className = '' }: { className?: string }) { ) } +function LanguageIcon({ className = '' }: { className?: string }) { + return ( + + ) +} + export default function Sidebar({ collapsed, onToggle }: SidebarProps) { const navigate = useNavigate() const location = useLocation() const { logout, isSubUser } = useAuth() const { theme, toggleTheme } = useTheme() + const { language, toggleLanguage, t } = useLanguage() const [version, setVersion] = useState('') useEffect(() => { @@ -99,7 +112,7 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) { +
+ + + +
{/* Version */} {version && ( diff --git a/frontend/src/contexts/LanguageContext.tsx b/frontend/src/contexts/LanguageContext.tsx new file mode 100644 index 0000000..06dfb88 --- /dev/null +++ b/frontend/src/contexts/LanguageContext.tsx @@ -0,0 +1,72 @@ +import { ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react' +import { translateText } from '../utils/i18n' +import { getLanguage, updateLanguage } from '../services/api' + +export type Language = 'zh' | 'en' + +interface LanguageContextValue { + language: Language + setLanguage: (language: Language) => void + toggleLanguage: () => Promise + t: (value: string) => string +} + +const LanguageContext = createContext(undefined) +function initialLanguage(): Language { + return 'zh' +} + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [language, setLanguageState] = useState(initialLanguage) + + const setLanguageLocal = (next: Language) => { + setLanguageState(next) + } + + const setLanguage = (next: Language) => { + setLanguageLocal(next) + updateLanguage(next).catch(() => {}) + } + + const value = useMemo(() => ({ + language, + setLanguage, + toggleLanguage: async () => { + const next = language === 'zh' ? 'en' : 'zh' + setLanguageLocal(next) + try { + const res = await updateLanguage(next) + setLanguageLocal(res.data.data?.language || next) + } catch { + setLanguageLocal(language) + } + }, + t: (text: string) => language === 'en' ? translateText(text) : text, + }), [language]) + + useEffect(() => { + getLanguage() + .then((res) => { + const serverLanguage = res.data.data?.language + if (serverLanguage === 'zh' || serverLanguage === 'en') { + setLanguageLocal(serverLanguage) + } + }) + .catch(() => {}) + }, []) + + useEffect(() => { + document.documentElement.lang = language === 'en' ? 'en' : 'zh-CN' + document.documentElement.dataset.language = language + }, [language]) + + return {children} +} + +export function useLanguage() { + const context = useContext(LanguageContext) + if (!context) { + throw new Error('useLanguage must be used within LanguageProvider') + } + return context +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5e5b2fe..a896230 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom' import App from './App' import { AuthProvider } from './contexts/AuthContext' import { ThemeProvider } from './contexts/ThemeContext' +import { LanguageProvider } from './contexts/LanguageContext' import { DialogProvider } from './components/Dialog' import './index.css' @@ -11,11 +12,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + , diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 5bde923..7247f43 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -2,9 +2,21 @@ import { FormEvent, useState } from 'react' import { Lock, User } from 'lucide-react' import AppIcon from '../components/AppIcon' import { useAuth } from '../contexts/AuthContext' +import { useLanguage } from '../contexts/LanguageContext' +import AutoTranslate from '../components/AutoTranslate' +import BrowserDialogTranslator from '../components/BrowserDialogTranslator' + +function LanguageIcon({ className = '' }: { className?: string }) { + return ( + + ) +} export default function Login() { const { login, accessCodeLogin } = useAuth() + const { language, toggleLanguage, t } = useLanguage() const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [error, setError] = useState('') @@ -29,7 +41,7 @@ export default function Login() { } } catch (err: unknown) { const error = err as { response?: { data?: { message?: string } } } - setError(error.response?.data?.message || '登录失败,请检查用户名和密码') + setError(error.response?.data?.message || t('登录失败,请检查用户名和密码')) } finally { setLoading(false) } @@ -37,6 +49,16 @@ export default function Login() { return (
+ + +
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 27b0da5..dca203f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -714,6 +714,15 @@ export const createWebSSHTicket = (containerName: string) => export const createVNCTicket = (containerName: string) => api.post>('/vnc-ticket', { container_name: containerName }) +// Language +export type PanelLanguage = 'zh' | 'en' + +export const getLanguage = () => + api.get>('/language') + +export const updateLanguage = (language: PanelLanguage) => + api.post>('/language', { language }) + // Version export const getVersion = () => api.get>('/version') diff --git a/frontend/src/utils/i18n.ts b/frontend/src/utils/i18n.ts new file mode 100644 index 0000000..bd94950 --- /dev/null +++ b/frontend/src/utils/i18n.ts @@ -0,0 +1,910 @@ +const exact: Record = { + '控制面板': 'Dashboard', + '共': 'Total', + '第': 'Page', + '页': 'page', + '个': 'items', + '条': 'records', + '核': 'cores', + '个容器': 'containers', + '条操作记录': 'audit records', + '个地址': 'addresses', + '列表': 'List', + '主机资源': 'Host Resources', + '主机资源状态': 'Host Resource Status', + '容器管理': 'Containers', + '镜像管理': 'Images', + '安全告警': 'Security Alerts', + '快照管理': 'Snapshots', + '路由管理': 'Routing', + '操作日志': 'Audit Logs', + '子用户管理': 'Sub Users', + 'API 集成': 'API Integration', + '宿主机信息': 'Host Info', + '面板设置': 'Panel Settings', + '退出登录': 'Log out', + '亮色模式': 'Light Mode', + '暗黑模式': 'Dark Mode', + '切换亮色模式': 'Switch to light mode', + '切换暗黑模式': 'Switch to dark mode', + '切换侧边栏': 'Toggle sidebar', + '刷新': 'Refresh', + '搜索': 'Search', + '复制': 'Copy', + '编辑': 'Edit', + '删除': 'Delete', + '保存': 'Save', + '提交': 'Submit', + '应用': 'Apply', + '查看': 'View', + '详情': 'Details', + '返回': 'Back', + '返回列表': 'Back to list', + '取消': 'Cancel', + '确认': 'Confirm', + '确定': 'OK', + '完成': 'Done', + '失败': 'Failed', + '成功': 'Success', + '提示': 'Notice', + '警告': 'Warning', + '开机': 'Start', + '关机': 'Stop', + '重启': 'Restart', + '重装': 'Reinstall', + '创建': 'Create', + '在线': 'Online', + '离线': 'Offline', + '永久': 'Permanent', + '长期有效': 'No expiration', + '长期': 'No expiration', + '不限制': 'Unlimited', + '未设置流量限制': 'No traffic limit set', + '未设置': 'Not set', + '已选': 'Selected', + ',已选': ', selected', + '筛选后': 'Filtered', + ',筛选后': ', filtered', + '每页数量': 'Items per page', + '任务中': 'In task', + '1周': '1 week', + '1 周': '1 week', + '资源配置': 'Resource Configuration', + '实时状态': 'Live Status', + '连接信息': 'Connection Info', + '管理链接': 'Management Link', + 'NAT 管理': 'NAT Management', + '快照': 'Snapshots', + '系统': 'System', + '全部类型': 'All types', + '全部系统': 'All systems', + '全部状态': 'All statuses', + '类型筛选': 'Type filter', + '系统筛选': 'System filter', + '状态筛选': 'Status filter', + '内网': 'Private IP', + '内网 IP': 'Private IP', + '策略封禁': 'Policy Blocked', + '已封禁': 'Blocked', + '已到期': 'Expired', + '识别码': 'Identifier', + 'CPU 累计时间': 'CPU Total Time', + '创建时间': 'Created At', + '网络速率': 'Network Speed', + 'IO 速度': 'IO Speed', + '月流量': 'Monthly Traffic', + '统计信息': 'Statistics', + 'CPU 使用率': 'CPU Usage', + '内存使用': 'Memory Usage', + '网络流量': 'Network Traffic', + '磁盘IO': 'Disk IO', + '磁盘 IO': 'Disk IO', + '负载': 'Load', + '平均': 'Average', + '峰值': 'Peak', + '容量': 'Capacity', + '累计': 'Total', + '读': 'Read', + '写': 'Write', + '入': 'In', + '出': 'Out', + '运行中': 'Running', + '已停止': 'Stopped', + '已完成': 'Completed', + '等待中': 'Pending', + '执行中': 'Running', + '未知': 'Unknown', + '必要': 'Required', + '可选': 'Optional', + '用户名': 'Username', + '密码': 'Password', + '输入用户名': 'Enter username', + '输入密码': 'Enter password', + '登录': 'Log in', + '登录中...': 'Logging in...', + '登录失败,请检查用户名和密码': 'Login failed. Check your username and password.', + 'Authentication required': 'Authentication required', + 'Administrator permission required': 'Administrator permission required', + 'Method not allowed': 'Method not allowed', + 'Invalid request body': 'Invalid request body', + 'Invalid credentials': 'Invalid credentials', + 'Access denied': 'Access denied', + 'Access denied to this container': 'Access denied to this container', + 'Container not found': 'Container not found', + 'Template not found': 'Template not found', + 'Template is required': 'Template is required', + 'Template is not enabled or downloaded': 'Template is not enabled or downloaded', + 'Container name is required': 'Container name is required', + 'Container created successfully': 'Container created successfully', + 'Password changed successfully': 'Password changed successfully', + 'SSL settings saved': 'SSL settings saved', + 'Save SSL settings failed': 'Save SSL settings failed', + 'Task deleted': 'Task deleted', + 'Snapshot deleted': 'Snapshot deleted', + 'Snapshot restored': 'Snapshot restored', + 'Security check completed': 'Security check completed', + '当前密码不正确': 'Current password is incorrect', + '密码不正确': 'Password is incorrect', + '新密码至少 6 位': 'New password must be at least 6 characters', + '用户名至少 3 位': 'Username must be at least 3 characters', + '密码加密失败': 'Failed to hash password', + '保存配置失败': 'Failed to save configuration', + '密码修改成功': 'Password changed successfully', + '用户名修改成功': 'Username changed successfully', + '容器已到期,不允许此操作': 'Container has expired. This action is not allowed.', + '容器管理登录': 'Container Access Login', + '操作失败': 'Action failed', + '错误': 'Error', + '保存失败': 'Save failed', + '重装失败': 'Reinstall failed', + '密码重置失败': 'Password reset failed', + '端口配额已满': 'Port quota reached', + '输入错误': 'Input error', + '请输入有效的内部端口': 'Enter a valid internal port', + '密码长度必须为 8-64 位': 'Password length must be 8-64 characters', + '密码不能包含空白字符': 'Password cannot contain whitespace', + '密码至少需要包含字母': 'Password must contain at least one letter', + '密码至少需要包含数字': 'Password must contain at least one number', + '密码格式不正确': 'Invalid password format', + '策略临时封禁': 'Temporarily blocked by policy', + '虚拟机被策略临时封禁,暂不能执行操作。': 'This VM is temporarily blocked by policy and cannot perform actions.', + '确定要删除容器': 'Delete container', + '吗?此操作不可撤销。': '? This action cannot be undone.', + '容器名称不能包含空格': 'Container name cannot contain spaces', + '该容器名称已存在': 'Container name already exists', + '请填写容器名称并选择系统模板': 'Enter a container name and select a system template', + '资源配置有误': 'Invalid resource configuration', + '请按红色提示修改 vCPU、内存或磁盘配置': 'Fix the vCPU, memory, or disk fields marked in red', + '创建失败': 'Create failed', + '创建新容器': 'Create New Container', + '批量创建数量': 'Batch Count', + '虚拟化架构': 'Virtualization', + 'LXC 容器': 'LXC Container', + 'KVM 虚拟机': 'KVM VM', + '系统模板': 'System Template', + '搜索名称、ID、UUID、IP': 'Search name, ID, UUID, IP', + '带宽 (Mbps)': 'Bandwidth (Mbps)', + '双向统计': 'Total In+Out', + '入/出分离': 'Separate In/Out', + 'GB (0=不限制)': 'GB (0=unlimited)', + '入站 (GB)': 'Inbound (GB)', + '出站 (GB)': 'Outbound (GB)', + 'NAT 端口映射数量': 'NAT Port Mapping Count', + '子用户快照上限': 'Sub-user Snapshot Limit', + '到期时间': 'Expiration Time', + '不选择则长期有效;选择日期后,到期会自动关机。': 'Leave blank for no expiration. If a date is selected, the container will shut down automatically when it expires.', + '创建中...': 'Creating...', + '请输入 vCPU': 'Enter vCPU', + '内存 (MB)': 'Memory (MB)', + '磁盘 (GB)': 'Disk (GB)', + 'IO 速度 (MB/s)': 'IO Speed (MB/s)', + '将创建': 'Will create', + '暂无可用的': 'No available', + '不能小于': 'Cannot be less than', + '不能大于': 'Cannot be greater than', + 'KVM vCPU 必须是整数': 'KVM vCPU must be an integer', + '请输入内存': 'Enter memory', + '请输入磁盘': 'Enter disk', + '轮换失败': 'Rotation failed', + '获取操作日志失败': 'Failed to load audit logs', + '获取登录日志失败': 'Failed to load login logs', + '暂无子用户': 'No sub-users', + '容器名称': 'Container Name', + '最后登录': 'Last Login', + '从未登录': 'Never logged in', + '查看密码': 'View Password', + '查看操作日志': 'View Audit Logs', + '查看登录日志': 'View Login Logs', + '轮换密码': 'Rotate Password', + '轮换中...': 'Rotating...', + '用户': 'User', + '未保存,请轮换生成新密码': 'Not saved. Rotate to generate a new password.', + '操作时间': 'Action Time', + '登录时间': 'Login Time', + '登录 IP': 'Login IP', + '请输入当前密码以确认修改': 'Enter current password to confirm changes', + '至少填写新密码或新用户名中的一项': 'Enter at least a new password or a new username', + '用户名已修改': 'Username changed', + '用户名修改失败': 'Username change failed', + '密码已修改': 'Password changed', + '密码修改失败': 'Password change failed', + '下次登录生效': 'Takes effect at next login', + '修改失败': 'Change failed', + '账号、安全证书与登录日志': 'Account, certificates, and login logs', + 'SSL 设置已保存,服务正在重启。稍后请用新的协议重新打开面板。': 'SSL settings saved. The service is restarting. Reopen the panel with the new protocol shortly.', + 'SSL 设置已保存,重启 clicd 服务后生效。': 'SSL settings saved. Restart the clicd service to apply them.', + 'SSL 设置保存失败': 'Failed to save SSL settings', + '纯 IP 证书需要服务器安装 Certbot 5.4+,且验证时 80 端口必须能被 Let’s Encrypt 访问。IP 证书是短有效期证书,certbot 需要保持自动续签。': 'Pure IP certificates require Certbot 5.4+ on the server, and port 80 must be reachable by Let’s Encrypt during validation. IP certificates are short-lived, so certbot auto-renewal must remain enabled.', + '自签证书可以加密面板和 VNC,但浏览器会提示证书不受信任;证书快到期时系统会自动重新签发。': 'Self-signed certificates can encrypt the panel and VNC, but browsers will show an untrusted certificate warning. The system will renew them automatically before expiration.', + '上传来源还没有保存证书,请粘贴证书和私钥后保存。': 'No certificate has been saved for the uploaded source. Paste the certificate and private key, then save.', + '当前来源还没有保存证书,保存 SSL 设置时会自动生成或申请。': 'No certificate has been saved for the current source. It will be generated or requested when SSL settings are saved.', + '暂无容器': 'No containers', + '暂无快照': 'No snapshots', + '暂无操作日志': 'No audit logs', + '暂无登录日志': 'No login logs', + '暂无登录记录': 'No login records', + '暂无 NAT4 端口映射': 'No NAT4 port mappings', + '暂无 IPv6 地址分配': 'No IPv6 assignments', + '暂无镜像': 'No images', + '暂无数据': 'No data', + '容器': 'Container', + '名称': 'Name', + '状态': 'Status', + '剩余时间': 'Time Left', + '配置': 'Config', + '镜像': 'Image', + '内存': 'Memory', + '磁盘': 'Disk', + '流量': 'Traffic', + '操作': 'Actions', + '类型': 'Type', + '创建者': 'Creator', + '管理员密码': 'Admin Password', + 'SSH 密码': 'SSH Password', + 'SSH 地址': 'SSH Address', + 'RDP 地址': 'RDP Address', + 'VNC 端口': 'VNC Port', + '点击隐藏': 'Click to hide', + '点击显示': 'Click to show', + '编辑资源限制': 'Edit resource limits', + '编辑流量限制': 'Edit traffic limit', + '修改到期时间': 'Change expiration time', + '新 SSH 密码': 'New SSH Password', + '生成随机密码': 'Generate random password', + '密码已修改成功': 'Password changed successfully', + '修改中...': 'Changing...', + '确认修改': 'Confirm Change', + '容器不存在': 'Container not found', + '容器未运行,请先开机': 'Container is not running. Start it first.', + 'VNC 控制台暂不可用,请确认 KVM 虚拟机已开机并刷新页面': 'VNC console is unavailable. Make sure the KVM VM is running and refresh the page.', + '虚拟机被策略临时封禁': 'VM temporarily blocked by policy', + '虚拟机被策略临时封禁,连接信息暂不可用。': 'This VM is temporarily blocked by policy. Connection info is unavailable.', + '已达到管理员分配的 NAT 端口配额。': 'The NAT port quota assigned by the administrator has been reached.', + '保存端口映射失败': 'Failed to save port mapping', + '删除端口映射失败': 'Failed to delete port mapping', + '删除映射': 'Delete Mapping', + '确定要删除这条映射规则吗?': 'Delete this mapping rule?', + '快照配额已满': 'Snapshot quota reached', + '已达到管理员设置的快照配额,请先删除旧快照。': 'The snapshot quota set by the administrator has been reached. Delete old snapshots first.', + '拍摄快照': 'Take Snapshot', + '拍摄快照需要先关机,完成后会自动重启容器': 'Taking a snapshot requires shutdown first. The container will restart automatically afterward', + '是否继续?': 'Continue?', + '创建快照失败': 'Failed to create snapshot', + '参数错误': 'Invalid parameters', + '自动快照周期最低是 1 天一次。': 'The minimum automatic snapshot interval is once per day.', + '定时快照失败': 'Scheduled snapshot failed', + '保存快照配额失败': 'Failed to save snapshot quota', + '删除快照失败': 'Failed to delete snapshot', + '恢复快照失败': 'Failed to restore snapshot', + '确定恢复到': 'Restore to', + '当前容器数据会被覆盖。': 'Current container data will be overwritten.', + '确定删除': 'Delete', + '的快照吗?': 'snapshot?', + '新建快照': 'New Snapshot', + '定时设置': 'Schedule Settings', + '定时快照': 'Scheduled Snapshot', + '处理中...': 'Processing...', + '快照数量:': 'Snapshot count:', + '子用户配额:': 'Sub-user quota:', + '定时状态:': 'Schedule status:', + '下次执行:': 'Next run:', + '未开启': 'Off', + '已开启': 'On', + '每': 'Every', + '执行': 'run', + '子用户每台容器快照上限': 'Sub-user snapshot limit per container', + '自动快照周期': 'Automatic snapshot interval', + '天': 'days', + '小时': 'hours', + '分钟': 'minutes', + '秒': 'seconds', + '大小': 'Size', + '手动': 'Manual', + '定时': 'Scheduled', + '时间': 'Time', + '设备': 'Device', + '结果': 'Result', + '地址': 'Address', + '前缀': 'Prefix', + '出口网卡': 'Uplink', + '协议': 'Protocol', + '说明': 'Description', + '端口': 'Port', + '容器端口': 'Container Port', + '宿主机端口': 'Host Port', + '容器 IPv4': 'Container IPv4', + 'IPv6 地址': 'IPv6 Address', + 'LXC 名称': 'LXC Name', + '快照时间': 'Snapshot Time', + '删除快照': 'Delete Snapshot', + '全局快照列表': 'Global snapshot list', + '主机名': 'Hostname', + '操作系统': 'Operating System', + '内核': 'Kernel', + '生成时间': 'Generated At', + '系统概览': 'System Overview', + '公网与路由': 'Public Network & Routing', + '内存条': 'Memory Modules', + '硬盘与健康': 'Disks & Health', + '网卡': 'Network Interfaces', + '显卡': 'GPUs', + '环境支持': 'Environment Support', + '服务管理器 systemd/OpenRC': 'Service Manager systemd/OpenRC', + 'LXC 创建工具': 'LXC Create Tool', + 'LXC 启动工具': 'LXC Start Tool', + 'iptables 网络规则': 'iptables Network Rules', + 'iproute2 网络工具': 'iproute2 Network Tool', + 'conntrack 安全扫描': 'conntrack Security Scan', + 'QEMU/KVM 虚拟机': 'QEMU/KVM Virtualization', + 'KVM cloud-init ISO 工具': 'KVM cloud-init ISO Tool', + 'ISO 备用工具': 'ISO Fallback Tool', + '硬盘健康检测': 'Disk Health Check', + 'Certbot 证书工具 >= 5.4': 'Certbot Certificate Tool >= 5.4', + '/dev/kvm 硬件虚拟化': '/dev/kvm Hardware Virtualization', + 'IPv4 转发': 'IPv4 Forwarding', + 'lxcfs 服务': 'lxcfs Service', + 'libvirt 服务': 'libvirt Service', + '正在探测宿主机环境...': 'Probing host environment...', + '暂未获取到宿主机信息': 'No host information available', + '面板资源状态与容器概览': 'Panel resource status and container overview', + '宿主机资源状态与容器概览': 'Host resource status and container overview', + '账号设置': 'Account Settings', + '当前用户名': 'Current Username', + '新用户名,留空则不修改': 'New Username, leave blank to keep unchanged', + '新密码,留空则不修改': 'New Password, leave blank to keep unchanged', + '当前密码,验证身份': 'Current Password, for verification', + '至少 3 位': 'At least 3 characters', + '至少 6 位': 'At least 6 characters', + '输入当前密码以确认修改': 'Enter current password to confirm changes', + '保存修改': 'Save Changes', + 'SSL 证书': 'SSL Certificate', + '启用 HTTPS / WSS': 'Enable HTTPS / WSS', + 'IP / 域名': 'IP / Domain', + '服务器公网 IP 或域名': 'Server public IP or domain', + '邮箱,可选': 'Email, optional', + '自签证书': 'Self-signed Certificate', + '上传证书': 'Uploaded Certificate', + '证书 PEM / fullchain.pem': 'Certificate PEM / fullchain.pem', + '私钥 PEM / privkey.pem': 'Private Key PEM / privkey.pem', + '保存后自动重启服务并立即生效': 'Restart service automatically after saving', + '保存中...': 'Saving...', + '保存 SSL 设置': 'Save SSL Settings', + '登录日志': 'Login Logs', + '首页': 'First', + '上一页': 'Previous', + '下一页': 'Next', + '末页': 'Last', + '搜索端口/容器...': 'Search port/container...', + '搜索地址/容器...': 'Search address/container...', + 'NAT4 端口': 'NAT4 Ports', + 'NAT4 端口分配': 'NAT4 Port Allocation', + 'IPv6 地址分配': 'IPv6 Address Allocation', + '剩余端口 / 端口总数': 'Available Ports / Total Ports', + '剩余地址 / 地址总数': 'Available Addresses / Total Addresses', + '已分配': 'Allocated', + '充足': 'Enough', + '容器列表': 'Container List', + '刷新列表': 'Refresh list', + '创建容器': 'Create Container', + '点击"创建容器"开始': 'Click "Create Container" to start', + '创建中': 'Creating', + '批量创建': 'Batch Create', + '导入容器': 'Import Container', + '重置密码': 'Reset Password', + 'WebSSH': 'WebSSH', + 'WebVNC': 'WebVNC', + '发送 Ctrl+Alt+Del': 'Send Ctrl+Alt+Del', + '重新连接': 'Reconnect', + '关闭': 'Close', + '已连接': 'Connected', + '连接中...': 'Connecting...', + '已断开': 'Disconnected', + '连接失败': 'Connection Failed', + '正在连接 KVM VNC 控制台...': 'Connecting to KVM VNC console...', + 'WebVNC 已断开': 'WebVNC disconnected', + '下载': 'Download', + '下载中': 'Downloading', + '启用': 'Enable', + '停用': 'Disable', + '已启用': 'Enabled', + '未启用': 'Disabled', + '系统镜像': 'System Images', + '安全检查': 'Security Check', + '告警列表': 'Alert List', + '自动关机已开': 'Auto-stop on', + '自动关机已关': 'Auto-stop off', + '暂无安全告警': 'No security alerts', + '严重': 'Critical', + '高': 'High', + '中': 'Medium', + '低': 'Low', + '管理员': 'Admin', + '子用户': 'Sub User', + '公网 IPv4': 'Public IPv4', + 'IPv4 地址': 'IPv4 Addresses', + 'IPv4 段': 'IPv4 Prefixes', + 'IPv6 段': 'IPv6 Prefixes', + '网关': 'Gateways', + 'CPU 架构': 'CPU Architecture', + 'CPU 虚拟化指令': 'CPU Virtualization Flags', + 'CPU 核显': 'Integrated GPU', + '运行能力': 'Runtime Capability', + 'KVM 嵌套虚拟化': 'KVM Nested Virtualization', + '支持': 'Supported', + '未检测到': 'Not detected', + '检测到': 'Detected', + '有效': 'Valid', + '已过期或未生效': 'Expired or not active', + '是': 'Yes', + '否': 'No', + '开启': 'On', + '已关闭': 'Off', + '自动': 'Auto', + '默认': 'Default', + '全部': 'All', + '无': 'None', + '根目录': 'Root', + '版本': 'Version', + '当前': 'Current', + '最近': 'Recent', + '来源': 'Source', + '目标': 'Target', + '描述': 'Description', + '备注': 'Notes', + '搜索容器...': 'Search containers...', + '搜索镜像...': 'Search images...', + '搜索日志...': 'Search logs...', + '复制成功': 'Copied', + '复制失败': 'Copy failed', + '请稍后重试': 'Please try again later', + '请稍后重试。': 'Please try again later.', + '开机中...': 'Starting...', + '关机中...': 'Stopping...', + '重启中...': 'Restarting...', + '删除中...': 'Deleting...', + '重装中...': 'Reinstalling...', + '开机中': 'Starting', + '关机中': 'Stopping', + '重启中': 'Restarting', + '删除中': 'Deleting', + '重装中': 'Reinstalling', + '正在初始化': 'Initializing', + '容器总数': 'Total Containers', + '驱动/速率': 'Driver / Speed', + '支持 KVM + LXC': 'KVM + LXC supported', + '仅支持 LXC': 'LXC only', + '未满足运行环境': 'Runtime requirements not met', + '健康': 'Healthy', + '异常': 'Abnormal', + '核显': 'Integrated', + '独显': 'Discrete', + '获取镜像列表失败': 'Failed to load image list', + '下载失败': 'Download failed', + '删除失败': 'Delete failed', + '取消失败': 'Cancel failed', + '删除镜像': 'Delete Image', + '确定要删除该镜像缓存吗?删除后需要重新下载才能使用。': 'Delete this image cache? You must download it again before using it.', + '取消下载并清理临时文件': 'Cancel download and clean temporary files', + '删除镜像缓存': 'Delete image cache', + '取消中': 'Cancelling', + '取消中...': 'Cancelling...', + '下载中...': 'Downloading...', + '阶段:': 'Stage:', + '转换中': 'Converting', + '端口扫描': 'Port scan', + '横向扫描': 'Lateral scan', + '暴力破解': 'Brute force', + 'DDoS/大规模扫描': 'DDoS / large-scale scan', + '垃圾邮件': 'Spam', + '恶意软件': 'Malware', + '挖矿连接': 'Mining connection', + '代理/VPN/Tor': 'Proxy / VPN / Tor', + 'UDP反射放大': 'UDP reflection amplification', + '高危': 'High risk', + '中危': 'Medium risk', + '低危': 'Low risk', + '告警自动关机': 'Auto shutdown on alerts', + '相关连接记录': 'Related Connection Records', + '查看相关记录': 'View related records', + '告警原始记录': 'Raw Alert Record', + '正在加载连接记录...': 'Loading connection records...', + '暂无可用连接记录。历史告警对应的 conntrack 记录可能已经过期。': 'No connection records available. Conntrack records for historical alerts may have expired.', + '源地址': 'Source Address', + '目标地址': 'Target Address', + '源IP': 'Source IP', + '次数': 'Count', + '等级': 'Severity', + '总览与只读': 'Overview & Read-only', + '路由信息': 'Routing Info', + 'IPv6 状态': 'IPv6 Status', + '镜像列表': 'Image List', + '查看容器': 'View Container', + '开关机/重启': 'Power / Restart', + '重装系统': 'Reinstall OS', + '资源/到期': 'Resources / Expiration', + '流量管理': 'Traffic Management', + '端口映射': 'Port Mappings', + '分配 IPv6': 'Assign IPv6', + '快照与终端': 'Snapshots & Terminal', + '查看快照': 'View Snapshots', + '创建快照': 'Create Snapshot', + '恢复快照': 'Restore Snapshot', + '计划/配额': 'Schedule / Quota', + '平台管理': 'Platform Management', + '下载镜像': 'Download Image', + '启停镜像': 'Enable / Disable Image', + '安全数据': 'Security Data', + '安全扫描': 'Security Scan', + '安全设置': 'Security Settings', + 'Swap 信息': 'Swap Info', + 'Swap 管理': 'Swap Management', + 'Key 列表': 'Key List', + '创建 Key': 'Create Key', + '更新 Key': 'Update Key', + '删除 Key': 'Delete Key', + '总览': 'Overview', + 'NAT/IPv6 路由': 'NAT / IPv6 Routing', + '任务队列': 'Task Queue', + '任务列表': 'Task List', + '操作记录': 'audit records', + '子用户列表': 'Sub-user List', + '创建子用户': 'Create Sub-user', + '更新子用户': 'Update Sub-user', + '管理员接口': 'Admin API', + '控制面板统计': 'Dashboard Stats', + '立即安全检查': 'Run Security Check', + '返回响应样例': 'Response Example', + '请求参数': 'Request Parameters', + '响应字段': 'Response Fields', + '接口地址': 'Endpoint', + '请求方法': 'Method', + '权限范围': 'Scopes', + '绑定容器': 'Bound Containers', + '全部容器': 'All Containers', + '全权限': 'Full Access', + '取消全权限': 'Remove Full Access', + '禁用这个 Key': 'Disable this key', + '过期时间': 'Expiration Time', + '永不过期': 'Never expires', + 'IP 白名单': 'IP Whitelist', + '密钥名称': 'Key Name', + '删除任务': 'Delete Task', + '容器详情': 'Container Details', + '资源用量': 'Resource Usage', + '流量统计': 'Traffic Stats', + '重置流量': 'Reset Traffic', + '调整流量限制': 'Adjust Traffic Limit', + '调整资源限制': 'Adjust Resource Limit', + '重置 SSH 密码': 'Reset SSH Password', + '端口与快照': 'Ports & Snapshots', + '随机可用端口': 'Random Available Port', + '添加端口映射': 'Add Port Mapping', + '更新端口映射': 'Update Port Mapping', + '删除端口映射': 'Delete Port Mapping', + '快照总览': 'Snapshot Overview', + '容器快照': 'Container Snapshots', + '计划快照': 'Scheduled Snapshots', + '快照配额': 'Snapshot Quota', + '模板列表': 'Template List', + '取消镜像下载': 'Cancel Image Download', + '启用/禁用镜像': 'Enable / Disable Image', + '安全连接日志': 'Security Connection Logs', + '安全汇总': 'Security Summary', + '更新安全设置': 'Update Security Settings', + '调整 Swap': 'Adjust Swap', + '批量开关机/删除/重装': 'Batch power/delete/reinstall', + '账号与日志': 'Account & Logs', + 'API Key 列表': 'API Key List', + '创建 API Key': 'Create API Key', + '更新 API Key': 'Update API Key', + '删除 API Key': 'Delete API Key', + '30分钟': '30 minutes', + '1小时': '1 hour', + '1天': '1 day', + '切换中文': 'Switch to Chinese', + 'WebSSH ticket 创建失败,请重新登录后再试': 'Failed to create WebSSH ticket. Log in again and retry.', + 'WebSSH ticket 为空,请重新登录后再试': 'WebSSH ticket is empty. Log in again and retry.', + 'WebSSH 连接失败,请确认容器已运行且 SSH 服务可用': 'WebSSH connection failed. Make sure the container is running and SSH is available.', + 'WebVNC ticket 创建失败,请重新登录后再试': 'Failed to create WebVNC ticket. Log in again and retry.', + 'WebVNC ticket 为空,请重新登录后再试': 'WebVNC ticket is empty. Log in again and retry.', + 'WebVNC 连接已断开,请确认虚拟机正在运行且 VNC 控制台可用': 'WebVNC disconnected. Make sure the VM is running and the VNC console is available.', + 'VNC 安全协商失败': 'VNC security negotiation failed', + '当前 VNC 控制台要求密码,暂不支持自动输入': 'This VNC console requires a password. Automatic input is not supported yet.', + '删除容器': 'Delete Container', + 'WebSSH 票据': 'WebSSH Ticket', + 'WebVNC 票据': 'WebVNC Ticket', + '容器列表(兼容旧接口)': 'Container List (legacy-compatible API)', + '调整到期时间': 'Adjust Expiration Time', + '镜像管理列表': 'Image Management List', + '批量创建容器': 'Batch Create Containers', + '创建 WebSSH 票据': 'Create WebSSH Ticket', + '创建 WebVNC 票据': 'Create WebVNC Ticket', + '创建子用户链接': 'Create Sub-user Link', + '轮换子用户密码': 'Rotate Sub-user Password', + '子用户操作日志': 'Sub-user Audit Logs', + '子用户登录日志': 'Sub-user Login Logs', + '确定删除这个 API Key 吗?': 'Delete this API Key?', + '管理外部调用凭据、权限范围与平台 API 文档': 'Manage external credentials, permission scopes, and platform API docs', + '新的 API Key 已生成': 'New API Key generated', + '已复制': 'Copied', + '加载中...': 'Loading...', + '暂无 API Key': 'No API Keys', + '权限': 'Permissions', + '限制': 'Limits', + '最后使用': 'Last Used', + '已禁用': 'Disabled', + '不限 IP': 'Any IP', + '从未使用': 'Never used', + 'API 文档': 'API Docs', + '查看使用范例': 'View examples', + 'Python 使用范例': 'Python example', + '编辑 API Key': 'Edit API Key', + 'CI/CD、计费系统、自动化脚本': 'CI/CD, billing systems, automation scripts', + 'SWAP 已调整为 16384 MB': 'SWAP adjusted to 16384 MB', + '***60秒有效票据***': '***60-second valid ticket***', + 'WebVNC 仅适用于 KVM 虚拟机;LXC 容器会返回 VNC console is only available for KVM VMs。': 'WebVNC only applies to KVM VMs; LXC containers return "VNC console is only available for KVM VMs".', + '该接口会进入任务队列,请随后调用 GET /api/v1/tasks 查看执行状态。': 'This API enters the task queue. Call GET /api/v1/tasks afterward to check execution status.', + '样例中的密钥、密码和票据已脱敏;创建类接口的完整密钥只在创建响应中出现一次。': 'Keys, passwords, and tickets in examples are masked. Full secrets from create APIs appear only once in the creation response.', + '编辑月流量限制': 'Edit Monthly Traffic Limit', + '流量统计模式': 'Traffic Accounting Mode', + '双向合并统计': 'Combined In+Out', + '入站/出站分开统计': 'Separate Inbound/Outbound', + '月流量上限 (GB,0=不限制)': 'Monthly traffic limit (GB, 0=unlimited)', + '入站上限 (GB,0=不限制)': 'Inbound limit (GB, 0=unlimited)', + '出站上限 (GB,0=不限制)': 'Outbound limit (GB, 0=unlimited)', + '请输入 8-64 位,至少包含字母和数字': 'Enter 8-64 characters, including at least letters and numbers', + 'Linux LXC/KVM 修改 root SSH 密码通常无需重启;KVM 需要虚拟机运行且 guest agent 或 SSH 可用。': 'Changing the root SSH password for Linux LXC/KVM usually does not require a restart. KVM requires the VM to be running and guest agent or SSH to be available.', + '退出全屏': 'Exit Fullscreen', + '全屏显示': 'Fullscreen', + '全屏': 'Fullscreen', + '修改': 'Change', + '关闭定时': 'Disable Schedule', + '执行时间': 'Run Time', + 'NAT 端口管理': 'NAT Port Management', + '添加映射': 'Add Mapping', + '端口配额:': 'Port quota:', + '已达到管理员分配的 NAT 端口配额': 'The NAT port quota assigned by the administrator has been reached', + '修改端口映射': 'Edit Port Mapping', + '重装系统会删除容器内所有数据,请谨慎操作。': 'Reinstalling the OS will delete all data in the container. Proceed carefully.', + '选择新系统模板': 'Select New System Template', + '确认重装': 'Confirm Reinstall', + '当前:': 'Current:', + '新到期日期(留空为长期有效)': 'New expiration date (leave blank for no expiration)', + 'vCPU 核数': 'vCPU Cores', + '网络速率 (Mbps,0=不限制)': 'Network speed (Mbps, 0=unlimited)', + 'IO 速度 (MB/s,0=不限制)': 'IO speed (MB/s, 0=unlimited)', + '磁盘容量不支持动态修改。修改后运行中的容器会立即应用新的 cgroup 限制。': 'Disk capacity cannot be changed dynamically. Running containers apply the new cgroup limits immediately.', + '恢复': 'Restore', + '全部 (ALL)': 'All (ALL)', + '外部端口': 'External Port', + '默认同内部': 'Same as internal by default', + '随机空闲端口': 'Random Free Port', + '随机': 'Random', + '内部端口': 'Internal Port', + '例如 80': 'e.g. 80', + '暂无端口映射': 'No port mappings', + '默认 SSH 映射不能删除': 'Default SSH mapping cannot be deleted', + '入站 (RX)': 'Inbound (RX)', + '(不限制)': '(unlimited)', + '出站 (TX)': 'Outbound (TX)', + '已用': 'Used', + '重置': 'Reset', + '执行中...': 'Running...', + '点击': 'Click', + '开始': 'Start', + '没有匹配的容器': 'No matching containers', + '显示': 'Showing', + '初始化失败': 'Initialization failed', + '初始化完成': 'Initialization complete', + '排队等待': 'Queued', + '处理中': 'Processing', + '未知系统': 'Unknown system', + '处理失败': 'Failed', + '暂无任务': 'No tasks', + '取消任务': 'Cancel Task', + '硬件、网络、磁盘健康与运行环境探测报告': 'Hardware, network, disk health, and runtime environment report', + '运行状态': 'Runtime Status', + '未检测到内存条明细,可能缺少 dmidecode 或权限受限': 'No memory module details detected. dmidecode may be missing or permissions may be limited.', + '插槽': 'Slot', + '频率': 'Frequency', + '厂商': 'Vendor', + '型号/序列号': 'Model / Serial', + '未检测到硬盘': 'No disks detected', + '型号': 'Model', + '挂载点': 'Mount Point', + '寿命': 'Lifetime', + '通电': 'Power-on', + '读取': 'Reads', + '写入': 'Writes', + '命令数': 'Commands', + '擦写': 'Erase Count', + '未检测到网卡': 'No network interfaces detected', + '未检测到显卡': 'No GPUs detected', + '驱动': 'Driver', + '管理 LXC / KVM 系统镜像,下载后的镜像才能用于创建容器/虚拟机。': 'Manage LXC / KVM system images. Downloaded images can be used to create containers/VMs.', + '已下载': 'Downloaded', + 'LXC 容器镜像': 'LXC Container Images', + 'KVM 虚拟机镜像': 'KVM VM Images', + '发行版': 'Distribution', + '架构': 'Architecture', + '禁用': 'Disable', + '可用': 'Available', + '未下载': 'Not downloaded', + '中文': 'Chinese', + '宿主机分配给 LXC 的 NAT4 端口和 IPv6 地址': 'NAT4 ports and IPv6 addresses assigned to LXC by the host', + '确认删除容器': 'Delete container', + '剩余地址 / 地址总数 ·': 'Available Addresses / Total Addresses ·', + '结果 ': 'Result ', + '告警列表 (': 'Alert List (', + '当前证书:': 'Current certificate:', + '到期时间:': 'Expires:', + '证书路径:': 'Certificate path:', + '最近错误:': 'Last error:', + '1 天': '1 day', + '3 天': '3 days', + '7 天': '7 days', + '14 天': '14 days', + '10 / 页': '10 / page', + '20 / 页': '20 / page', + '50 / 页': '50 / page', + '全局快照列表,共': 'Global snapshot list, total', + '容器分配的子用户列表,共': 'Sub-user list assigned to containers, total', +} + +const artifactPatterns: RegExp[] = [ + /Back\s*列表/, + /SearchName、ID、UUID、IP/, + /All(Type|Status|系统)/, + /AutoStop\s*已[开关]/, + /暂\s*(None|无)\s*Security Alerts/, + /Memory\s*使用/, + /网络\s*Traffic/, + /实时\s*Status/, + /Create\s*Time/, + /长期\s*Valid/, +] + +const replacements: Array<[RegExp, string]> = [ + [/Back\s*列表/g, 'Back to list'], + [/Search\s*名称、ID、UUID、IP/g, 'Search name, ID, UUID, IP'], + [/All\s*类型/g, 'All types'], + [/All\s*系统/g, 'All systems'], + [/All\s*状态/g, 'All statuses'], + [/AllType/g, 'All types'], + [/All系统/g, 'All systems'], + [/AllStatus/g, 'All statuses'], + [/SearchName、ID、UUID、IP/g, 'Search name, ID, UUID, IP'], + [/AutoStop\s*已关/g, 'Auto-stop off'], + [/AutoStop\s*已开/g, 'Auto-stop on'], + [/暂\s*None\s*Security Alerts/g, 'No security alerts'], + [/暂\s*无\s*Security Alerts/g, 'No security alerts'], + [/WebVNC\s*初始化失败(.+)$/g, 'WebVNC initialization failed$1'], + [/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'], + [/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'], + [/拍摄快照需要先关机,完成后会自动重启容器\s*(.+?)。是否继续?/g, 'Taking a snapshot requires shutdown first. Container $1 will restart automatically afterward. Continue?'], + [/确定删除\s*(.+?)\s*的快照吗?/g, 'Delete snapshot $1?'], + [/确定恢复到\s*(.+?)\s*的快照吗?当前容器数据会被覆盖。/g, 'Restore to snapshot $1? Current container data will be overwritten.'], + [/旧版\s*\/api\/containers\/list\s*已兼容,但新接入请使用\s*GET\s*\/api\/v1\/containers/g, 'Legacy /api/containers/list remains compatible, but new integrations should use GET /api/v1/containers'], + [/到期\s*(.+)$/g, 'Expires $1'], + [/支持\s*\((.+?)\)/g, 'Supported ($1)'], + [/下载中\s*(.+)$/g, 'Downloading $1'], + [/结果\s*(.+)$/g, 'Result $1'], + [/磨损\s*(.+)$/g, 'Wear $1'], + [/擦写\s*(.+)$/g, 'Erase $1'], + [/启停\s*(.+)$/g, 'Power cycles $1'], + [/每\s*(.+)$/g, 'Every $1'], + [/已开启,每\s*(.+)$/g, 'Enabled, every $1'], + [/告警列表\s*\((\d+)\)/g, 'Alert List ($1)'], + [/共\s*(\d+)\s*个\s*Container/g, 'Total $1 containers'], + [/共\s*(\d+)\s*个\s*容器/g, 'Total $1 containers'], + [/共\s*(\d+)\s*条/g, 'Total $1'], + [/共\s*(\d+)\s*个/g, 'Total $1 items'], + [/,筛选后\s*(\d+)\s*个/g, ', filtered $1 items'], + [/,已选\s*(\d+)\s*个/g, ', selected $1 items'], + [/第\s*(\d+)\/(\d+)\s*页/g, 'Page $1/$2'], + [/显示\s*(\d+)-(\d+)\s*\/\s*(\d+)/g, 'Showing $1-$2 / $3'], + [/显示\s*(\d+)-(\d+),共\s*(\d+)\s*条/g, 'Showing $1-$2 of $3'], + [/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*条,/g, 'Search "$1" returned $2 results, '], + [/搜索\s*"([^"]+)"\s*结果\s*(\d+)\s*个地址/g, 'Search "$1" returned $2 addresses, '], + [/(\d+)\s*个/g, '$1 items'], + [/(\d+)\s*条/g, '$1 records'], + [/(\d+)\s*核/g, '$1 cores'], + [/(\d+)\s*线程/g, '$1 threads'], + [/已用/g, 'used'], + [/未设置\s*Traffic\s*限制/g, 'No traffic limit set'], + [/Memory\s*使用/g, 'Memory Usage'], + [/网络\s*Traffic/g, 'Network Traffic'], + [/实时\s*Status/g, 'Live Status'], + [/Expiration Time\s*长期\s*Valid/g, 'Expiration Time No expiration'], + [/长期\s*Valid/g, 'No expiration'], + [/Create\s*Time/g, 'Created At'], + [/CPU\s*累计\s*Time/g, 'CPU Total Time'], + [/(\d+(?:\.\d+)?)\s*cores\s*\/\s*(\d+)\s*核/g, '$1 cores / $2 cores'], + [/(\d+)\s*核\/(.+?)\/(\d+)\s*GB/g, '$1 cores / $2 / $3 GB'], + [/(\d+)\s*\/\s*页/g, '$1 / page'], + [/到期时间:/g, 'Expires: '], + [/证书路径:/g, 'Certificate path: '], + [/最近错误:/g, 'Last error: '], + [/当前证书:/g, 'Current certificate: '], + [/第\s*(\d+)\s*页/g, 'Page $1'], + [/入\s*([^/,]+)\s*\/\s*出\s*([^,]+),累计\s*(.+)$/g, 'In $1 / Out $2, total $3'], + [/读\s*([^/,]+)\s*\/\s*写\s*([^,]+),累计\s*([^,]+),容量\s*(.+)$/g, 'Read $1 / Write $2, total $3, capacity $4'], + [/(.+?),筛选后\s*(\d+)\s*items/g, '$1, filtered $2 items'], + [/(.+?),已选\s*(\d+)\s*items/g, '$1, selected $2 items'], + [/将创建\s*(\d+)\s*个容器:(.+?)\s*至\s*(.+)$/g, 'Will create $1 containers: $2 to $3'], + [/暂无可用的\s*(KVM|LXC)\s*系统镜像,请先在「镜像管理」中下载镜像模板。/g, 'No available $1 system images. Download image templates in Images first.'], + [/不能小于\s*(.+)$/g, 'Cannot be less than $1'], + [/不能大于\s*(.+)$/g, 'Cannot be greater than $1'], + [/^(.+?)\s*-\s*操作日志$/g, '$1 - Audit Logs'], + [/^(.+?)\s*-\s*登录日志$/g, '$1 - Login Logs'], + [/^(.+?)。下次登录生效$/g, '$1. Takes effect at next login'], + [/阶段:(.+)$/g, 'Stage: $1'], + [/\$\{days\}天/g, '${days} days'], + [/\$\{hours\}小时/g, '${hours} hours'], + [/\$\{hours\}\s*小时/g, '${hours} hours'], + [/\$\{Math\.floor\(diff \/ 60000\)\}分钟/g, '${Math.floor(diff / 60000)} minutes'], + [/(\d+)分钟/g, '$1 minutes'], + [/(\d+)小时/g, '$1 hours'], + [/(\d+)\s*周/g, '$1 weeks'], + [/(\d+)天/g, '$1 days'], + [/确认删除容器\s*(.+?)\s*的快照吗?此操作不可恢复。/g, 'Delete the snapshot for container $1? This cannot be undone.'], + [/确定要删除容器\s*(.+?)\s*吗?此操作不可撤销。/g, 'Delete container $1? This action cannot be undone.'], + [/容器\s*(.+?)\s*已开机/g, 'Container $1 started'], + [/容器\s*(.+?)\s*已关机/g, 'Container $1 stopped'], + [/容器\s*(.+?)\s*已重启/g, 'Container $1 restarted'], +] + +export function translateText(value: string): string { + if (!shouldTranslateText(value)) return value + const leading = value.match(/^\s*/)?.[0] || '' + const trailing = value.match(/\s*$/)?.[0] || '' + const body = value.trim() + if (!body) return value + if (exact[body]) return leading + exact[body] + trailing + let translated = body + for (const [pattern, replacement] of replacements) { + translated = translated.replace(pattern, replacement) + } + for (const [source, target] of Object.entries(exact).sort((a, b) => b[0].length - a[0].length)) { + translated = translated.split(source).join(target) + } + translated = cleanupTranslatedText(translated) + return leading + translated + trailing +} + +export function shouldTranslateText(value: string): boolean { + return /[\u3400-\u9fff]/.test(value) || artifactPatterns.some((pattern) => pattern.test(value)) +} + +function cleanupTranslatedText(value: string): string { + return value + .replace(/Back\s*List/g, 'Back to list') + .replace(/Container\s*List/g, 'Container List') + .replace(/Snapshot\s*List/g, 'Snapshot List') + .replace(/All\s*Type/g, 'All types') + .replace(/All\s*Status/g, 'All statuses') + .replace(/All\s*System/g, 'All systems') + .replace(/AutoStop\s*Off/g, 'Auto-stop off') + .replace(/AutoStop\s*On/g, 'Auto-stop on') + .replace(/\s{2,}/g, ' ') +} diff --git a/install.sh b/install.sh index be66e63..9d9b968 100644 --- a/install.sh +++ b/install.sh @@ -11,8 +11,218 @@ LOG_FILE="${CLICD_LOG_FILE:-/var/log/clicd-install.log}" INSTALL_DOWNLOAD_MARKER="${CLICD_INSTALL_DOWNLOAD_MARKER:-/tmp/clicd-install-dir.$$}" LIBVIRT_DEFAULT_MARKER="/var/lib/clicd/kvm/default-network.created" +normalize_lang() { + lang="$1" + case "$(printf '%s' "$lang" | tr 'A-Z' 'a-z')" in + en|en_*|en-*) echo en ;; + zh|zh_*|zh-*) echo zh ;; + *) echo "" ;; + esac +} + +detect_lang() { + normalized="$(normalize_lang "${CLICD_LANG:-}")" + if [ -n "$normalized" ]; then + echo "$normalized" + return + fi + normalized="$(normalize_lang "${LC_ALL:-${LC_MESSAGES:-${LANG:-}}}")" + if [ -n "$normalized" ]; then + echo "$normalized" + return + fi + echo zh +} + +choose_language() { + normalized="$(normalize_lang "${CLICD_LANG:-}")" + if [ -n "$normalized" ]; then + echo "$normalized" + return + fi + case "$ACTION" in + -h|--help|help) + detect_lang + return + ;; + esac + if [ -t 0 ]; then + { + echo "=====================================" + echo " 请选择语言 / Select language" + echo "=====================================" + echo " 1) 简体中文" + echo " 2) English" + printf " 请输入 1/2 [1]: " + } >&2 + IFS= read -r answer || answer="" + case "$answer" in + 2) echo en ;; + *) echo zh ;; + esac + return + fi + if [ -r /dev/tty ] && [ -w /dev/tty ] && { printf '' > /dev/tty; } 2>/dev/null; then + { + echo "=====================================" + echo " 请选择语言 / Select language" + echo "=====================================" + echo " 1) 简体中文" + echo " 2) English" + printf " 请输入 1/2 [1]: " + } > /dev/tty + IFS= read -r answer < /dev/tty || answer="" + case "$answer" in + 2) echo en ;; + *) echo zh ;; + esac + return + fi + detect_lang +} + +CLICD_LANG_DETECTED="$(choose_language)" +export CLICD_LANG="$CLICD_LANG_DETECTED" + +tr_msg() { + msg="$*" + [ "$CLICD_LANG_DETECTED" = "en" ] || { printf '%s' "$msg"; return; } + msg="$(printf '%s' "$msg" | sed \ + -e 's/中文安装\/卸载脚本/Installer\/Uninstaller/g' \ + -e 's/警告/Warning/g' \ + -e 's/错误/Error/g' \ + -e 's/安装\/卸载未完成。请查看日志:/Install\/uninstall did not complete. Check log: /g' \ + -e 's/如果你确认这是程序问题,请提交 issue:/If this looks like a CLICD bug, please open an issue: /g' \ + -e 's/开始:/Starting: /g' \ + -e 's/完成:/Completed: /g' \ + -e 's/步骤失败:/Step failed: /g' \ + -e 's/退出码:/exit code: /g' \ + -e 's/最近 80 行日志:/Last 80 log lines: /g' \ + -e 's/请将上述日志和系统信息提交到:/Please submit the log above and system info to: /g' \ + -e 's/系统检测:/System check: /g' \ + -e 's/当前安装包仅支持/This installer only supports/g' \ + -e 's/当前架构:/current architecture: /g' \ + -e 's/未检测到 systemd 或 OpenRC,无法安装服务。/systemd or OpenRC was not detected; cannot install service./g' \ + -e 's/暂不支持当前 Linux 发行版:/Unsupported Linux distribution: /g' \ + -e 's/请提交 issue 并附上/Please open an issue with/g' \ + -e 's/发行版/Distribution/g' \ + -e 's/不在主要支持列表,将按检测到的软件包管理器尝试安装。/is not in the primary support list; trying the detected package manager./g' \ + -e 's/存储检测:/Storage check: /g' \ + -e 's/根文件系统=/root filesystem=/g' \ + -e 's/可用空间=/available space=/g' \ + -e 's/根分区可用空间低于 5GB,下载镜像或创建 KVM\/LXC 时可能失败。/Root partition has less than 5GB available; image downloads or KVM\/LXC creation may fail./g' \ + -e 's/请使用 root 权限运行:/Run as root: /g' \ + -e 's/或执行:/Or run: /g' \ + -e 's/卸载:/Uninstall: /g' \ + -e 's/问题反馈:/Issues: /g' \ + -e 's/日志文件:/Log file: /g' \ + -e 's/仓库地址:/Repository: /g' \ + -e 's/未知操作:/Unknown action: /g' \ + -e 's/卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC\/KVM 实例和缓存数据。/Uninstall will stop and remove the CLICD service, configuration database, CLICD-created LXC\/KVM instances, and cached data./g' \ + -e 's/为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-\* 下载临时容器和 vm-数字 的 KVM 域。/To avoid deleting production data, the script only removes LXC containers named ct-NUMBER, temporary clicd-img-dl-* download containers, and KVM domains named vm-NUMBER./g' \ + -e 's/如需确认卸载,请输入:YES/Type YES to confirm uninstall:/g' \ + -e 's/已取消卸载。如需非交互卸载,请设置 CLICD_UNINSTALL_CONFIRM=1。/Uninstall cancelled. For non-interactive uninstall, set CLICD_UNINSTALL_CONFIRM=1./g' \ + -e 's/正在卸载 CLICD.../Uninstalling CLICD.../g' \ + -e 's/正在删除 CLICD 创建的 LXC 容器(\/var\/lib\/lxc\/ct-数字).../Removing CLICD-created LXC containers (\/var\/lib\/lxc\/ct-NUMBER).../g' \ + -e 's/保留 \/root\/clicd-backups,避免误删部署\/回滚备份。确认不需要后可手动删除。/Keeping \/root\/clicd-backups to avoid deleting deployment\/rollback backups. Remove it manually if no longer needed./g' \ + -e 's/CLICD 卸载完成/CLICD uninstall complete/g' \ + -e 's/已删除服务、二进制、SQLite\/配置数据、CLICD LXC\/KVM 实例、/Removed service, binary, SQLite\/config data, CLICD LXC\/KVM instances,/g' \ + -e 's/CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。/CLICD image cache, firewall rules, host hooks, quota records, and temporary files./g' \ + -e 's/已保留 \/root\/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份\/共享镜像。/Kept \/root\/clicd-backups and non-CLICD global LXC cache to avoid deleting production backups\/shared images./g' \ + -e 's/日志:/Log: /g' \ + -e 's/兼容性检查/Compatibility check/g' \ + -e 's/存储环境检查/Storage environment check/g' \ + -e 's/安装系统依赖/Install system dependencies/g' \ + -e 's/配置内核网络参数/Configure kernel networking/g' \ + -e 's/配置运行时服务/Configure runtime services/g' \ + -e 's/配置 libvirt default NAT 网络/Configure libvirt default NAT network/g' \ + -e 's/配置 UID\/GID 映射/Configure UID\/GID mapping/g' \ + -e 's/配置 LXC 存储权限/Configure LXC storage permissions/g' \ + -e 's/检查 project quota/Check project quota/g' \ + -e 's/下载发行版包/Download release package/g' \ + -e 's/安装 CLICD 二进制/Install CLICD binary/g' \ + -e 's/安装并启动 CLICD 服务/Install and start CLICD service/g' \ + -e 's/已写入面板语言:/Panel language saved: /g' \ + -e 's/写入面板语言/Save panel language/g' \ + -e 's/面板语言写入失败,请安装后在面板右下角手动切换。/Failed to save panel language. Please switch it manually from the lower-left panel control after installation./g' \ + -e 's/正在使用 apk 安装依赖.../Installing dependencies with apk.../g' \ + -e 's/正在使用 apt 安装依赖.../Installing dependencies with apt.../g' \ + -e 's/正在使用 dnf 安装依赖.../Installing dependencies with dnf.../g' \ + -e 's/正在使用 yum 安装依赖.../Installing dependencies with yum.../g' \ + -e 's/依赖安装后仍未找到/Still missing after dependency installation: /g' \ + -e 's/,请检查 LXC 软件源\/安装日志。/. Check the LXC repository\/install log./g' \ + -e 's/,请检查系统网络工具包。/. Check the system network tools package./g' \ + -e 's/,请检查 iproute2 安装。/. Check the iproute2 installation./g' \ + -e 's/,请检查 libvirt-client\/libvirt-clients 安装。/. Check the libvirt-client\/libvirt-clients installation./g' \ + -e 's/,请检查 qemu-utils\/qemu-img 安装。/. Check the qemu-utils\/qemu-img installation./g' \ + -e 's/,请检查 cloud-image-utils\/cloud-utils 安装。/. Check the cloud-image-utils\/cloud-utils installation./g' \ + -e 's/可选依赖未安装:/Optional dependency was not installed: /g' \ + -e 's/当前系统 /Current system /g' \ + -e 's/ 未找到 dnf\/yum,无法安装依赖。/ does not have dnf\/yum; cannot install dependencies./g' \ + -e 's/Windows KVM 初始化需要 genisoimage、mkisofs 或 xorriso 中任意一个。/Windows KVM initialization requires one of genisoimage, mkisofs, or xorriso./g' \ + -e 's/未检测到 \/dev\/kvm。LXC 可用,但 KVM 虚拟机需要硬件虚拟化或嵌套虚拟化。/\/dev\/kvm was not detected. LXC is available, but KVM VMs require hardware virtualization or nested virtualization./g' \ + -e 's/正在启用内核转发配置.../Enabling kernel forwarding settings.../g' \ + -e 's/正在配置 LXC 和 KVM 服务.../Configuring LXC and KVM services.../g' \ + -e 's/服务 /Service /g' \ + -e 's/ 启动失败,将继续安装并在运行时降级处理。/ failed to start; installation will continue and runtime fallback will be used./g' \ + -e 's/未检测到 systemd 单元 /systemd unit was not detected: /g' \ + -e 's/,跳过。/; skipped./g' \ + -e 's/检测到 libvirt 传统 libvirtd 服务,已使用 libvirtd 模式。/Detected the legacy libvirt libvirtd service; using libvirtd mode./g' \ + -e 's/未检测到支持的服务管理器。CLICD 当前支持 systemd 或 OpenRC。/No supported service manager was detected. CLICD currently supports systemd or OpenRC./g' \ + -e 's/正在检查 libvirt default NAT 网络.../Checking libvirt default NAT network.../g' \ + -e 's/未找到 virsh,跳过 libvirt default NAT 网络检查。/virsh was not found; skipping the libvirt default NAT network check./g' \ + -e 's/libvirt default 网络仍未启动。请执行 virsh net-info default 查看详情。/libvirt default network is still not active. Run virsh net-info default for details./g' \ + -e 's/libvirt default NAT 网络已启用。/libvirt default NAT network is enabled./g' \ + -e 's/正在配置 subordinate UID\/GID 范围.../Configuring subordinate UID\/GID ranges.../g' \ + -e 's/根文件系统 /Root filesystem /g' \ + -e 's/ 不需要\/不适合自动启用 ext4 project quota,CLICD 将使用兼容磁盘限制模式。/ does not need or is not suitable for automatic ext4 project quota; CLICD will use compatible disk limit mode./g' \ + -e 's/ 不在自动 project quota 支持范围,CLICD 将使用兼容磁盘限制模式。/ is not supported for automatic project quota; CLICD will use compatible disk limit mode./g' \ + -e 's/根分区来源 /Root partition source /g' \ + -e 's/ 不是块设备,跳过 project quota 自动检查,CLICD 将使用兼容磁盘限制模式。/ is not a block device; skipping automatic project quota check and using compatible disk limit mode./g' \ + -e 's/未找到 tune2fs,跳过 project quota 检查,CLICD 将使用兼容磁盘限制模式。/tune2fs was not found; skipping project quota check and using compatible disk limit mode./g' \ + -e 's/检测到 ext4 project quota 已可用。/ext4 project quota is already available./g' \ + -e 's/ext4 project quota 未启用,CLICD 将自动回退到 loopback 镜像磁盘限制模式。/ext4 project quota is not enabled; CLICD will automatically fall back to loopback image disk limit mode./g' \ + -e 's/当前目录未找到 clicd 二进制,将下载发行版包。/No local clicd binary found; downloading release package./g' \ + -e 's/正在下载发行版包:/Downloading release package: /g' \ + -e 's/下载发行版包需要 curl 或 wget。/Downloading the release package requires curl or wget./g' \ + -e 's/下载的发行版包中未找到 clicd 二进制。/The downloaded release package does not contain the clicd binary./g' \ + -e 's/未找到 clicd 二进制,安装无法继续。/clicd binary was not found; installation cannot continue./g' \ + -e 's/已安装二进制:/Installed binary: /g' \ + -e 's/正在安装 CLICD 服务.../Installing CLICD service.../g' \ + -e 's/正在清理 CLICD 防火墙和网桥规则.../Cleaning CLICD firewall and bridge rules.../g' \ + -e 's/已清理 /Cleaned /g' \ + -e 's/ 中的 CLICD 配额记录/ CLICD quota records/g' \ + -e 's/跳过当前安装目录 /Skipping current installation directory /g' \ + -e 's/,避免中断后续安装步骤。/ to avoid interrupting later installation steps./g' \ + -e 's/ 被占用,终止占用进程后重试删除.../ is busy; killing occupying processes and retrying removal.../g' \ + -e 's/正在删除 CLICD 使用的 LXC 镜像缓存.../Removing LXC image cache used by CLICD.../g' \ + -e 's/正在删除 KVM 虚拟机域 /Removing KVM VM domain /g' \ + -e 's/正在销毁 CLICD 创建的 KVM 虚拟机.../Destroying CLICD-created KVM VMs.../g' \ + -e 's/检测到非 CLICD 虚拟机仍在使用 libvirt default 网络,已保留 default\/virbr0。/Non-CLICD VMs are still using the libvirt default network, so default\/virbr0 has been kept./g' \ + -e 's/正在删除 CLICD 创建的 libvirt default NAT 网络.../Removing CLICD-created libvirt default NAT network.../g' \ + -e 's/已删除 /Removed /g' \ + -e 's/检测到 /Detected /g' \ + -e 's/安装完成/Installation complete/g' \ + -e 's/Web 面板/Web panel/g' \ + -e 's/二进制/Binary/g' \ + -e 's/安装日志/Install log/g' \ + -e 's/服务/Service/g' \ + -e 's/运行日志/Runtime log/g' \ + -e 's/首次安装时的初始账号信息:/Initial account information for first installation:/g' \ + -e 's/如果没有显示密码,说明服务器已有/If no password is shown, the server already has/g' \ + -e 's/已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。/Existing admin passwords are stored with bcrypt and cannot be recovered. Change it in the panel or reset configuration./g' \ + -e 's/:/: /g' \ + -e 's/,/, /g' \ + -e 's/。/./g' \ + -e 's/(/(/g' \ + -e 's/)/)/g' \ + -e 's/、/, /g' \ + )" + printf '%s' "$msg" +} + echo "=====================================" -echo " CLICD 中文安装/卸载脚本" +echo " $(tr_msg "CLICD 中文安装/卸载脚本")" echo "=====================================" write_log_file() { @@ -22,21 +232,26 @@ write_log_file() { } log() { - echo "[clicd] $*" - write_log_file "[clicd] $*" + msg="$(tr_msg "$*")" + echo "[clicd] $msg" + write_log_file "[clicd] $msg" } warn() { - echo "[clicd][警告] $*" >&2 - write_log_file "[警告] $*" + label="$(tr_msg "警告")" + msg="$(tr_msg "$*")" + echo "[clicd][$label] $msg" >&2 + write_log_file "[$label] $msg" } die() { - echo "[clicd][错误] $*" >&2 - write_log_file "[错误] $*" + label="$(tr_msg "错误")" + msg="$(tr_msg "$*")" + echo "[clicd][$label] $msg" >&2 + write_log_file "[$label] $msg" echo "" >&2 - echo "安装/卸载未完成。请查看日志:$LOG_FILE" >&2 - echo "如果你确认这是程序问题,请提交 issue:$ISSUE_URL" >&2 + echo "$(tr_msg "安装/卸载未完成。请查看日志:")$LOG_FILE" >&2 + echo "$(tr_msg "如果你确认这是程序问题,请提交 issue:")$ISSUE_URL" >&2 exit 1 } @@ -62,11 +277,11 @@ run_step() { fi rc="$?" echo "" >&2 - echo "[clicd][错误] 步骤失败:$step_name,退出码:$rc" >&2 - echo "[clicd][错误] 最近 80 行日志:$LOG_FILE" >&2 + echo "[clicd][$(tr_msg "错误")] $(tr_msg "步骤失败:")$(tr_msg "$step_name")$(tr_msg ",")$(tr_msg "退出码:")$rc" >&2 + echo "[clicd][$(tr_msg "错误")] $(tr_msg "最近 80 行日志:")$LOG_FILE" >&2 tail -n 80 "$LOG_FILE" >&2 2>/dev/null || true echo "" >&2 - echo "请将上述日志和系统信息提交到:$ISSUE_URL" >&2 + echo "$(tr_msg "请将上述日志和系统信息提交到:")$ISSUE_URL" >&2 exit "$rc" } @@ -104,10 +319,10 @@ check_storage_compatibility() { } if [ "$(id -u)" -ne 0 ]; then - echo "请使用 root 权限运行:sudo ./install.sh" - echo "或执行:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh" - echo "卸载:curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall" - echo "问题反馈:$ISSUE_URL" + echo "$(tr_msg "请使用 root 权限运行:")sudo ./install.sh" + echo "$(tr_msg "或执行:")curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh" + echo "$(tr_msg "卸载:")curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall" + echo "$(tr_msg "问题反馈:")$ISSUE_URL" exit 1 fi @@ -125,6 +340,28 @@ if [ -r /etc/os-release ]; then fi usage() { + if [ "$CLICD_LANG_DETECTED" = "en" ]; then + cat << EOF +Usage: + ./install.sh Install or upgrade CLICD + ./install.sh uninstall Uninstall CLICD (removes containers, VMs, image cache, and config data) + +Environment variables: + CLICD_REPO=owner/repo Default: ${REPO} + CLICD_VERSION=latest|v1.0.0 Default: latest + CLICD_LANG=en|zh Default: auto + CLICD_LOG_FILE=/path/file.log Default: ${LOG_FILE} + +Examples: + curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh + curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall + curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | sudo sh -s -- uninstall --yes + +Log: ${LOG_FILE} +Issues: ${ISSUE_URL} +EOF + return + fi cat << EOF 用法: ./install.sh 安装或升级 CLICD @@ -133,6 +370,7 @@ usage() { 环境变量: CLICD_REPO=owner/repo 默认:${REPO} CLICD_VERSION=latest|v1.0.0 默认:latest + CLICD_LANG=en|zh 默认:自动检测 CLICD_LOG_FILE=/path/file.log 默认:${LOG_FILE} 示例: @@ -598,9 +836,9 @@ confirm_uninstall() { return fi echo "" - echo "[clicd][警告] 卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。" >&2 - echo "[clicd][警告] 为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。" >&2 - echo "如需确认卸载,请输入:YES" >&2 + echo "[clicd][$(tr_msg "警告")] $(tr_msg "卸载会停止并删除 CLICD 服务、配置数据库、CLICD 创建的 LXC/KVM 实例和缓存数据。")" >&2 + echo "[clicd][$(tr_msg "警告")] $(tr_msg "为避免误删生产数据,脚本只会删除名称形如 ct-数字 的 LXC 容器、clicd-img-dl-* 下载临时容器和 vm-数字 的 KVM 域。")" >&2 + echo "$(tr_msg "如需确认卸载,请输入:YES")" >&2 if [ -r /dev/tty ]; then IFS= read -r answer < /dev/tty elif [ -t 0 ]; then @@ -667,13 +905,13 @@ uninstall_clicd() { echo "" echo "=====================================" - echo " CLICD 卸载完成" + echo " $(tr_msg "CLICD 卸载完成")" echo "=====================================" - echo " 已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、" - echo " CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。" - echo " 已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。" - echo " 日志:$LOG_FILE" - echo " 问题反馈:$ISSUE_URL" + echo " $(tr_msg "已删除服务、二进制、SQLite/配置数据、CLICD LXC/KVM 实例、")" + echo " $(tr_msg "CLICD 镜像缓存、防火墙规则、主机钩子、配额记录和临时文件。")" + echo " $(tr_msg "已保留 /root/clicd-backups 和非 CLICD 的 LXC 全局缓存,避免误删生产备份/共享镜像。")" + echo " $(tr_msg "日志:")$LOG_FILE" + echo " $(tr_msg "问题反馈:")$ISSUE_URL" echo "=====================================" } @@ -1279,33 +1517,83 @@ install_service() { fi } +set_panel_language() { + lang="$CLICD_LANG_DETECTED" + db="/root/.clicd/config.db" + if [ "$lang" != "zh" ] && [ "$lang" != "en" ]; then + lang="zh" + fi + + saved=0 + i=0 + while [ ! -f "$db" ] && [ "$i" -lt 20 ]; do + i=$((i + 1)) + sleep 1 + done + + if [ -f "$db" ] && has_cmd python3; then + CLICD_PANEL_LANG="$lang" CLICD_DB="$db" python3 - <<'PY' >/dev/null 2>&1 && saved=1 || saved=0 +import os +import sqlite3 + +db = os.environ["CLICD_DB"] +lang = os.environ["CLICD_PANEL_LANG"] +conn = sqlite3.connect(db) +conn.execute("CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)") +conn.execute("INSERT OR REPLACE INTO app_meta(key, value) VALUES('language', ?)", (lang,)) +conn.commit() +conn.close() +PY + fi + + if [ "$saved" != "1" ] && [ -f "$db" ] && has_cmd sqlite3; then + sqlite3 "$db" "CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); INSERT OR REPLACE INTO app_meta(key, value) VALUES('language', '$lang');" >/dev/null 2>&1 && saved=1 || saved=0 + fi + + if [ "$saved" != "1" ] && has_cmd curl; then + curl -k -fsS -X POST -H 'Content-Type: application/json' -d "{\"language\":\"$lang\"}" "https://127.0.0.1:8999/api/language" >/dev/null 2>&1 && saved=1 || \ + curl -fsS -X POST -H 'Content-Type: application/json' -d "{\"language\":\"$lang\"}" "http://127.0.0.1:8999/api/language" >/dev/null 2>&1 && saved=1 || saved=0 + fi + + if [ "$saved" = "1" ]; then + log "已写入面板语言:$lang" + if has_cmd systemctl && systemctl is-active clicd >/dev/null 2>&1; then + systemctl restart clicd >/dev/null 2>&1 || true + elif has_cmd rc-service; then + rc-service clicd restart >/dev/null 2>&1 || true + fi + else + warn "面板语言写入失败,请安装后在面板右下角手动切换。" + fi +} + print_summary() { echo "" echo "=====================================" - echo " 安装完成" + echo " $(tr_msg "安装完成")" echo "=====================================" - echo " Web 面板:http://YOUR_SERVER_IP:8999" - echo " 二进制:/usr/local/bin/clicd" - echo " 安装日志:$LOG_FILE" - echo " 问题反馈:$ISSUE_URL" + echo " $(tr_msg "Web 面板:")http://YOUR_SERVER_IP:8999" + echo " $(tr_msg "二进制:")/usr/local/bin/clicd" + echo " $(tr_msg "安装日志:")$LOG_FILE" + echo " $(tr_msg "问题反馈:")$ISSUE_URL" if is_systemd; then - echo " 服务:systemctl {start|stop|restart|status} clicd" - echo " 运行日志:journalctl -u clicd -f" + echo " $(tr_msg "服务:")systemctl {start|stop|restart|status} clicd" + echo " $(tr_msg "运行日志:")journalctl -u clicd -f" elif is_openrc; then - echo " 服务:rc-service clicd {start|stop|restart|status}" - echo " 运行日志:tail -f /var/log/clicd.log /var/log/clicd.err" + echo " $(tr_msg "服务:")rc-service clicd {start|stop|restart|status}" + echo " $(tr_msg "运行日志:")tail -f /var/log/clicd.log /var/log/clicd.err" fi echo "=====================================" echo "" - echo "首次安装时的初始账号信息:" + echo "$(tr_msg "首次安装时的初始账号信息:")" if is_systemd; then journalctl -u clicd --no-pager -n 80 | grep -E "Username:|Password:" || true else grep -E "Username:|Password:" /var/log/clicd.log /var/log/clicd.err 2>/dev/null || true fi echo "" - echo "如果没有显示密码,说明服务器已有 /root/.clicd/config.db。" - echo "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。" + echo "$(tr_msg "如果没有显示密码,说明服务器已有") /root/.clicd/config.db." + echo "$(tr_msg "已有管理员密码使用 bcrypt 存储,无法反查;请使用面板内修改密码或重置配置。")" } run_step "兼容性检查" check_os_compatibility @@ -1315,10 +1603,11 @@ run_step "配置内核网络参数" configure_kernel_networking run_step "配置运行时服务" setup_runtime_services run_step "配置 libvirt default NAT 网络" setup_default_libvirt_network run_step "配置 UID/GID 映射" setup_subids -run_step "Configure LXC storage permissions" configure_lxc_storage_access +run_step "配置 LXC 存储权限" configure_lxc_storage_access run_step "检查 project quota" try_enable_project_quota run_step "下载发行版包" download_release_if_needed run_step "安装 CLICD 二进制" install_binary run_step "安装并启动 CLICD 服务" install_service +run_step "写入面板语言" set_panel_language sleep 2 print_summary