mirror of
https://github.com/MengMengCode/CLICD.git
synced 2026-08-04 21:31:23 +08:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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)
|
||||
|
||||
+395
-115
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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))))
|
||||
|
||||
+27
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<Text>()
|
||||
const textOriginals = new WeakMap<Text, string>()
|
||||
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<Node>()
|
||||
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<HTMLElement>(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<HTMLElement>('[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<HTMLElement>(`[${translatedTitleAttr}]`).forEach((el) => {
|
||||
el.setAttribute('title', el.getAttribute(translatedTitleAttr) || '')
|
||||
el.removeAttribute(translatedTitleAttr)
|
||||
})
|
||||
root.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(`[${translatedPlaceholderAttr}]`).forEach((el) => {
|
||||
el.setAttribute('placeholder', el.getAttribute(translatedPlaceholderAttr) || '')
|
||||
el.removeAttribute(translatedPlaceholderAttr)
|
||||
})
|
||||
root.querySelectorAll<HTMLElement>(`[${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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<DialogContextType | undefined>(undefined)
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [dialog, setDialog] = useState<DialogState>({ open: false, type: 'alert', title: '', message: '' })
|
||||
const { t } = useLanguage()
|
||||
|
||||
const confirm = useCallback((title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
@@ -50,7 +52,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
}`}>
|
||||
{dialog.type === 'confirm' ? <AlertTriangle className="w-4 h-4" /> : <CheckCircle className="w-4 h-4" />}
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{dialog.title}</h3>
|
||||
<h3 className="text-sm font-semibold text-black flex-1">{t(dialog.title)}</h3>
|
||||
{dialog.type === 'alert' && (
|
||||
<button onClick={() => close(true)} className="p-1 text-gray-400 hover:text-black rounded">
|
||||
<X className="w-4 h-4" />
|
||||
@@ -58,7 +60,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-gray-600">{dialog.message}</p>
|
||||
<p className="text-sm text-gray-600">{t(dialog.message)}</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 px-5 py-3 bg-gray-50 border-t border-gray-100">
|
||||
{dialog.type === 'confirm' && (
|
||||
@@ -66,7 +68,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
onClick={() => close(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
取消
|
||||
{t('取消')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@@ -77,7 +79,7 @@ export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
: 'bg-black text-white hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
{dialog.type === 'confirm' ? '确认' : '确定'}
|
||||
{dialog.type === 'confirm' ? t('确认') : t('确定')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-gray-50 flex dark:bg-gray-950">
|
||||
<AutoTranslate />
|
||||
<BrowserDialogTranslator />
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} />
|
||||
<main className={`flex-1 transition-all duration-300 ${sidebarCollapsed ? 'ml-16' : 'ml-60'}`}>
|
||||
<div className="p-6">
|
||||
|
||||
@@ -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 (
|
||||
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path
|
||||
d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 rounded hover:bg-gray-100 text-gray-500 dark:hover:bg-gray-800 dark:text-gray-400"
|
||||
title="切换侧边栏"
|
||||
title={t('切换侧边栏')}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -253,18 +266,28 @@ export default function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-2 space-y-1">
|
||||
{/* Theme Toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
title={theme === 'dark' ? '切换亮色模式' : '切换暗黑模式'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
<div className={collapsed ? 'space-y-1' : 'flex items-center gap-1'}>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`${collapsed ? 'w-full justify-center' : 'flex-1'} flex items-center gap-3 px-3 py-2.5 rounded-md text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title={t(theme === 'dark' ? '切换亮色模式' : '切换暗黑模式')}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
{!collapsed && <span>{theme === 'dark' ? '亮色模式' : '暗黑模式'}</span>}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { void toggleLanguage() }}
|
||||
className={`${collapsed ? 'w-full' : 'w-10'} flex items-center justify-center rounded-md px-2 py-2.5 text-sm text-gray-600 hover:bg-gray-100 transition-colors dark:text-gray-400 dark:hover:bg-gray-800`}
|
||||
title={language === 'en' ? '切换中文' : 'Switch to English'}
|
||||
>
|
||||
<LanguageIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
{version && (
|
||||
|
||||
@@ -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<void>
|
||||
t: (value: string) => string
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextValue | undefined>(undefined)
|
||||
function initialLanguage(): Language {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(initialLanguage)
|
||||
|
||||
const setLanguageLocal = (next: Language) => {
|
||||
setLanguageState(next)
|
||||
}
|
||||
|
||||
const setLanguage = (next: Language) => {
|
||||
setLanguageLocal(next)
|
||||
updateLanguage(next).catch(() => {})
|
||||
}
|
||||
|
||||
const value = useMemo<LanguageContextValue>(() => ({
|
||||
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 <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within LanguageProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<DialogProvider>
|
||||
<App />
|
||||
</DialogProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -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 (
|
||||
<svg className={className} viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M213.333333 640v85.333333a85.333333 85.333333 0 0 0 78.933334 85.12L298.666667 810.666667h128v85.333333H298.666667a170.666667 170.666667 0 0 1-170.666667-170.666667v-85.333333h85.333333z m554.666667-213.333333l187.733333 469.333333h-91.946666l-51.242667-128h-174.506667l-51.157333 128h-91.904L682.666667 426.666667h85.333333z m-42.666667 123.093333L672.128 682.666667h106.325333L725.333333 549.76zM341.333333 85.333333v85.333334h170.666667v298.666666H341.333333v128H256v-128H85.333333V170.666667h170.666667V85.333333h85.333333z m384 42.666667a170.666667 170.666667 0 0 1 170.666667 170.666667v85.333333h-85.333333V298.666667a85.333333 85.333333 0 0 0-85.333334-85.333334h-128V128h128zM256 256H170.666667v128h85.333333V256z m170.666667 0H341.333333v128h85.333334V256z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<AutoTranslate />
|
||||
<BrowserDialogTranslator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void toggleLanguage() }}
|
||||
className="absolute right-4 top-4 inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-600 shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
<LanguageIcon className="h-3.5 w-3.5" />
|
||||
{language === 'en' ? '中文' : 'English'}
|
||||
</button>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-8">
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
|
||||
@@ -714,6 +714,15 @@ export const createWebSSHTicket = (containerName: string) =>
|
||||
export const createVNCTicket = (containerName: string) =>
|
||||
api.post<APIResponse<{ ticket: string }>>('/vnc-ticket', { container_name: containerName })
|
||||
|
||||
// Language
|
||||
export type PanelLanguage = 'zh' | 'en'
|
||||
|
||||
export const getLanguage = () =>
|
||||
api.get<APIResponse<{ language: PanelLanguage }>>('/language')
|
||||
|
||||
export const updateLanguage = (language: PanelLanguage) =>
|
||||
api.post<APIResponse<{ language: PanelLanguage }>>('/language', { language })
|
||||
|
||||
// Version
|
||||
export const getVersion = () =>
|
||||
api.get<APIResponse<{ version: string }>>('/version')
|
||||
|
||||
@@ -0,0 +1,910 @@
|
||||
const exact: Record<string, string> = {
|
||||
'控制面板': '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, ' ')
|
||||
}
|
||||
+327
-38
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user