diff --git a/internal/panel/app.go b/internal/panel/app.go index 9e0df3d..dd3b3ad 100644 --- a/internal/panel/app.go +++ b/internal/panel/app.go @@ -58,6 +58,8 @@ type ViewData struct { Flash string FlashKind string // "ok" | "err" | "" CurrentPage string + Locale string + LanguageURL string // dashboard RepoCount int @@ -67,6 +69,8 @@ type ViewData struct { ServerInfo ServerInfo RecentLines []string PayloadURL string // public /webhook URL for the guide; empty when accessed locally + Delivery DeliverySummary + Topology Topology // repos Repos []RepoRow @@ -159,6 +163,8 @@ func New(opts Options) (*App, error) { } base := template.New("layout.html").Funcs(template.FuncMap{ + "t": translate, + "percent": metricPercent, "eq": func(a, b string) bool { a = strings.TrimSpace(a) b = strings.TrimSpace(b) @@ -189,6 +195,7 @@ func New(opts Options) (*App, error) { for _, page := range []string{ "login", "dashboard", + "topology", "repos", "repo_edit", "bots", @@ -261,8 +268,10 @@ func (a *App) routes() http.Handler { mux.HandleFunc("/login", a.handleLogin) mux.HandleFunc("/logout", a.handleLogout) + mux.HandleFunc("/locale", a.handleLocale) mux.HandleFunc("/", a.requireAuth(a.handleDashboard)) + mux.HandleFunc("/topology", a.requireAuth(a.handleTopology)) mux.HandleFunc("/repos", a.requireAuth(a.handleRepos)) mux.HandleFunc("/repos/new", a.requireAuth(a.handleRepoNew)) mux.HandleFunc("/repos/edit", a.requireAuth(a.handleRepoEdit)) @@ -310,14 +319,30 @@ func (a *App) renderPage(w http.ResponseWriter, page string, data ViewData) { // baseData returns a ViewData pre-populated with auth + flash from the request. func (a *App) baseData(r *http.Request) ViewData { q := r.URL.Query() + locale := localeFrom(r) return ViewData{ - Authed: true, - Username: usernameFrom(r), - Flash: q.Get("flash"), - FlashKind: q.Get("kind"), + Authed: true, + Username: usernameFrom(r), + Locale: locale, + LanguageURL: "/locale?lang=" + alternateLocale(locale) + "&next=" + urlQueryEscape(r.URL.RequestURI()), + Flash: q.Get("flash"), + FlashKind: q.Get("kind"), } } +func (a *App) handleLocale(w http.ResponseWriter, r *http.Request) { + locale := r.URL.Query().Get("lang") + if !validLocale(locale) { + locale = "zh-CN" + } + http.SetCookie(w, &http.Cookie{Name: localeCookie, Value: locale, Path: "/", MaxAge: 365 * 24 * 60 * 60, SameSite: http.SameSiteLaxMode}) + next := r.URL.Query().Get("next") + if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") { + next = "/" + } + http.Redirect(w, r, next, http.StatusSeeOther) +} + // redirectFlash redirects to dest with a flash message rendered on arrival. func (a *App) redirectFlash(w http.ResponseWriter, r *http.Request, dest, flash, kind string) { u := dest @@ -390,3 +415,24 @@ func readRecentLogLines(logDir string, n int) []string { } return kept } + +func readDashboardLogLines(logDir string) []string { + entries, err := os.ReadDir(logDir) + if err != nil { + return nil + } + var newest os.DirEntry + for _, entry := range entries { + if !entry.IsDir() && (newest == nil || entry.Name() > newest.Name()) { + newest = entry + } + } + if newest == nil { + return nil + } + data, err := os.ReadFile(filepath.Join(logDir, newest.Name())) + if err != nil { + return nil + } + return strings.Split(strings.TrimSpace(string(data)), "\n") +} diff --git a/internal/panel/dashboard_metrics.go b/internal/panel/dashboard_metrics.go new file mode 100644 index 0000000..fa04881 --- /dev/null +++ b/internal/panel/dashboard_metrics.go @@ -0,0 +1,142 @@ +package panel + +import ( + "regexp" + "strings" + "time" +) + +var ( + logTimestamp = regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`) + matchedEvent = regexp.MustCompile(`Event matched: ([^,\s]+)`) + sentTarget = regexp.MustCompile(`Successfully sent notification to (.+)$`) + failedTarget = regexp.MustCompile(`Failed to send notification to (.+?)(?::|$)`) +) + +// DeliverySummary is deliberately payload-free. It is derived from normal +// application logs and is only used to make the operational dashboard useful. +type DeliverySummary struct { + Total int + Failed int + SuccessRate int + MaxDaily int + Days []DeliveryDay + Events []MetricItem + Recent []DeliveryRow +} + +type DeliveryDay struct { + Label string + Success int + Failed int +} + +type MetricItem struct { + Label string + Count int +} + +type DeliveryRow struct { + Time string + Target string + Success bool +} + +func summarizeDeliveries(lines []string, now time.Time) DeliverySummary { + start := now.AddDate(0, 0, -6) + days := make([]DeliveryDay, 7) + dayIndex := make(map[string]int, len(days)) + for i := range days { + day := start.AddDate(0, 0, i) + days[i].Label = day.Format("01/02") + dayIndex[day.Format("2006-01-02")] = i + } + + summary := DeliverySummary{Days: days} + events := map[string]int{} + for _, line := range lines { + if match := matchedEvent.FindStringSubmatch(line); len(match) == 2 { + events[match[1]]++ + } + + ok, target := deliveryFromLine(line) + if target == "" { + continue + } + ts, parsed := parseLogTime(line) + if !parsed { + continue + } + idx, exists := dayIndex[ts.Format("2006-01-02")] + if !exists { + continue + } + if ok { + summary.Days[idx].Success++ + } else { + summary.Days[idx].Failed++ + } + if count := summary.Days[idx].Success + summary.Days[idx].Failed; count > summary.MaxDaily { + summary.MaxDaily = count + } + summary.Total++ + if !ok { + summary.Failed++ + } + summary.Recent = append(summary.Recent, DeliveryRow{ + Time: ts.Format("01/02 15:04"), Target: target, Success: ok, + }) + } + if summary.Total > 0 { + summary.SuccessRate = (summary.Total - summary.Failed) * 100 / summary.Total + } + for label, count := range events { + summary.Events = append(summary.Events, MetricItem{Label: label, Count: count}) + } + summary.Events = sortedMetricItems(summary.Events) + if len(summary.Recent) > 8 { + summary.Recent = summary.Recent[len(summary.Recent)-8:] + } + for left, right := 0, len(summary.Recent)-1; left < right; left, right = left+1, right-1 { + summary.Recent[left], summary.Recent[right] = summary.Recent[right], summary.Recent[left] + } + return summary +} + +func metricPercent(value, total int) int { + if total <= 0 || value <= 0 { + return 0 + } + return value * 100 / total +} + +func deliveryFromLine(line string) (bool, string) { + if match := sentTarget.FindStringSubmatch(line); len(match) == 2 { + return true, strings.TrimSpace(match[1]) + } + if match := failedTarget.FindStringSubmatch(line); len(match) == 2 { + return false, strings.TrimSpace(match[1]) + } + return false, "" +} + +func parseLogTime(line string) (time.Time, bool) { + match := logTimestamp.FindStringSubmatch(line) + if len(match) != 2 { + return time.Time{}, false + } + ts, err := time.ParseInLocation("2006/01/02 15:04:05", match[1], time.Local) + return ts, err == nil +} + +func sortedMetricItems(items []MetricItem) []MetricItem { + for i := 1; i < len(items); i++ { + for j := i; j > 0 && (items[j].Count > items[j-1].Count || (items[j].Count == items[j-1].Count && items[j].Label < items[j-1].Label)); j-- { + items[j], items[j-1] = items[j-1], items[j] + } + } + if len(items) > 6 { + return items[:6] + } + return items +} diff --git a/internal/panel/dashboard_metrics_test.go b/internal/panel/dashboard_metrics_test.go new file mode 100644 index 0000000..31ac077 --- /dev/null +++ b/internal/panel/dashboard_metrics_test.go @@ -0,0 +1,85 @@ +package panel + +import ( + "net/http/httptest" + "regexp" + "testing" + "time" +) + +func TestSummarizeDeliveries(t *testing.T) { + now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.Local) + lines := []string{ + "2026/07/24 09:00:00 [INFO] Event matched: push, sending notification", + "2026/07/24 09:00:01 [INFO] Successfully sent notification to dev-team", + "2026/07/24 10:00:00 [INFO] Event matched: issues, sending notification", + "2026/07/24 10:00:01 [ERROR] Failed to send notification to ops-team: unavailable", + "2026/07/17 10:00:01 [INFO] Successfully sent notification to outside-window", + } + + got := summarizeDeliveries(lines, now) + if got.Total != 2 || got.Failed != 1 || got.SuccessRate != 50 { + t.Fatalf("unexpected summary: %#v", got) + } + if len(got.Recent) != 2 || got.Recent[0].Target != "ops-team" || got.Recent[0].Success { + t.Fatalf("unexpected recent deliveries: %#v", got.Recent) + } + if len(got.Events) != 2 || got.Events[0].Count != 1 { + t.Fatalf("unexpected events: %#v", got.Events) + } +} + +func TestLocaleFromAndTranslate(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Accept-Language", "en-GB,en;q=0.9") + if got := localeFrom(r); got != "en-US" { + t.Fatalf("localeFrom() = %q, want en-US", got) + } + if got := translate(ViewData{Locale: "en-US"}, "nav.overview"); got != "Overview" { + t.Fatalf("translate() = %q", got) + } + if got := translate(ViewData{Locale: "invalid"}, "nav.overview"); got != "概览" { + t.Fatalf("fallback = %q", got) + } +} + +func TestLocaleCatalogsHaveMatchingKeys(t *testing.T) { + for key := range messages["zh-CN"] { + if messages["en-US"][key] == "" { + t.Errorf("en-US is missing key %q", key) + } + } + for key := range messages["en-US"] { + if messages["zh-CN"][key] == "" { + t.Errorf("zh-CN is missing key %q", key) + } + } +} + +func TestTemplateLocaleKeysExistAndHaveNoMixedLanguageCopy(t *testing.T) { + keyPattern := regexp.MustCompile(`\{\{t\s+[$.]\s+"([^"]+)"`) + mixedCopy := regexp.MustCompile(`[\p{Han}]\s*/|/\s*[\p{Han}]`) + entries, err := templatesFS.ReadDir("templates") + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + data, err := templatesFS.ReadFile("templates/" + entry.Name()) + if err != nil { + t.Fatal(err) + } + for _, match := range keyPattern.FindAllStringSubmatch(string(data), -1) { + for locale, catalog := range messages { + if catalog[match[1]] == "" { + t.Errorf("%s references %q, missing in %s", entry.Name(), match[1], locale) + } + } + } + if mixedCopy.Match(data) { + t.Errorf("%s still contains mixed-language UI copy", entry.Name()) + } + } +} diff --git a/internal/panel/handlers_auth.go b/internal/panel/handlers_auth.go index 4b2c143..f998c62 100644 --- a/internal/panel/handlers_auth.go +++ b/internal/panel/handlers_auth.go @@ -25,18 +25,18 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { func (a *App) handleLoginPost(w http.ResponseWriter, r *http.Request) { if !a.Enabled() { - a.redirectFlash(w, r, "/login", "面板未配置管理员密码 / panel login not configured", "err") + a.redirectFlash(w, r, "/login", a.message(r, "flash.panelLoginDisabled"), "err") return } if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/login", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/login", a.message(r, "flash.invalidForm"), "err") return } wantUser, passHash := a.credentials() username := r.FormValue("username") password := r.FormValue("password") if username != wantUser || !auth.VerifyPassword(string(passHash), password) { - a.redirectFlash(w, r, "/login", "用户名或密码错误 / invalid username or password", "err") + a.redirectFlash(w, r, "/login", a.message(r, "flash.invalidCredentials"), "err") return } diff --git a/internal/panel/handlers_bots.go b/internal/panel/handlers_bots.go index 8761736..5d9cfba 100644 --- a/internal/panel/handlers_bots.go +++ b/internal/panel/handlers_bots.go @@ -36,12 +36,12 @@ func (a *App) handleBotEdit(w http.ResponseWriter, r *http.Request) { idx, _ := strconv.Atoi(r.URL.Query().Get("index")) cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.configLoadFailed"), "err") return } data.Templates = a.knownTemplates(cfg) if idx < 0 || idx >= len(cfg.FeishuBots.FeishuBots) { - a.redirectFlash(w, r, "/bots", "机器人不存在 / bot not found", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botNotFound"), "err") return } b := cfg.FeishuBots.FeishuBots[idx] @@ -52,7 +52,7 @@ func (a *App) handleBotEdit(w http.ResponseWriter, r *http.Request) { // handleBotSave creates or updates a bot. func (a *App) handleBotSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/bots", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.invalidForm"), "err") return } idx, _ := strconv.Atoi(r.FormValue("index")) @@ -60,13 +60,13 @@ func (a *App) handleBotSave(w http.ResponseWriter, r *http.Request) { url := strings.TrimSpace(r.FormValue("url")) tmpl := strings.TrimSpace(r.FormValue("template")) if alias == "" || url == "" { - a.redirectFlash(w, r, "/bots", "alias 和 url 不能为空 / alias and url required", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botFieldsRequired"), "err") return } cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.configLoadFailed"), "err") return } @@ -78,34 +78,34 @@ func (a *App) handleBotSave(w http.ResponseWriter, r *http.Request) { } if err := SaveYAML(a.cfgDir+"/feishu-bots.yaml", cfg.FeishuBots); err != nil { - a.redirectFlash(w, r, "/bots", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/bots", "机器人已保存 / bot saved", "ok") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botSaved"), "ok") } // handleBotDelete removes a bot by index. func (a *App) handleBotDelete(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/bots", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.invalidForm"), "err") return } idx, _ := strconv.Atoi(r.FormValue("index")) cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/bots", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.configLoadFailed"), "err") return } if idx < 0 || idx >= len(cfg.FeishuBots.FeishuBots) { - a.redirectFlash(w, r, "/bots", "机器人不存在 / bot not found", "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botNotFound"), "err") return } cfg.FeishuBots.FeishuBots = append(cfg.FeishuBots.FeishuBots[:idx], cfg.FeishuBots.FeishuBots[idx+1:]...) if err := SaveYAML(a.cfgDir+"/feishu-bots.yaml", cfg.FeishuBots); err != nil { - a.redirectFlash(w, r, "/bots", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/bots", "机器人已删除 / bot deleted", "ok") + a.redirectFlash(w, r, "/bots", a.message(r, "flash.botDeleted"), "ok") } diff --git a/internal/panel/handlers_dashboard.go b/internal/panel/handlers_dashboard.go index b16ae98..3418406 100644 --- a/internal/panel/handlers_dashboard.go +++ b/internal/panel/handlers_dashboard.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/hnrobert/feishu-github-tracker/internal/config" ) @@ -32,11 +33,19 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { publicURL = cfg.Server.Panel.PublicURL } data.PayloadURL = payloadURLFor(r, publicURL) - data.RecentLines = readRecentLogLines(a.logDir, 20) + data.Topology = topologyFromConfig(cfg) + data.Delivery = summarizeDeliveries(readDashboardLogLines(a.logDir), time.Now()) a.renderPage(w, "dashboard", data) } +func (a *App) handleTopology(w http.ResponseWriter, r *http.Request) { + data := a.baseData(r) + cfg, _ := a.loadConfig() + data.Topology = topologyFromConfig(cfg) + a.renderPage(w, "topology", data) +} + // payloadURLFor returns the /webhook URL to show in the setup guide. // // - If publicURL (panel.public_url) is set, it is used verbatim (always shown, diff --git a/internal/panel/handlers_events.go b/internal/panel/handlers_events.go index 03facbf..d761754 100644 --- a/internal/panel/handlers_events.go +++ b/internal/panel/handlers_events.go @@ -22,21 +22,21 @@ func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) { // valid, writes the raw text back verbatim. func (a *App) handleEventsSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/events", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/events", a.message(r, "flash.invalidForm"), "err") return } text := r.FormValue("events_yaml") var check config.EventsConfig if err := yaml.Unmarshal([]byte(text), &check); err != nil { - a.redirectFlash(w, r, "/events", "events.yaml 解析失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/events", a.message(r, "flash.eventsParseFailed", err), "err") return } if err := os.WriteFile(a.cfgDir+"/events.yaml", []byte(text), 0o644); err != nil { - a.redirectFlash(w, r, "/events", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/events", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/events", "事件配置已保存 / events saved", "ok") + a.redirectFlash(w, r, "/events", a.message(r, "flash.eventsSaved"), "ok") } diff --git a/internal/panel/handlers_repos.go b/internal/panel/handlers_repos.go index d596bc9..f1487f0 100644 --- a/internal/panel/handlers_repos.go +++ b/internal/panel/handlers_repos.go @@ -33,11 +33,11 @@ func (a *App) handleRepoEdit(w http.ResponseWriter, r *http.Request) { idx, _ := strconv.Atoi(r.URL.Query().Get("index")) cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.configLoadFailed"), "err") return } if idx < 0 || idx >= len(cfg.Repos.Repos) { - a.redirectFlash(w, r, "/repos", "仓库不存在 / repo not found", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.repoNotFound"), "err") return } data.EditRepo = repoEditRow(idx, cfg.Repos.Repos[idx]) @@ -47,19 +47,19 @@ func (a *App) handleRepoEdit(w http.ResponseWriter, r *http.Request) { // handleRepoSave creates or updates a repo rule. func (a *App) handleRepoSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/repos", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.invalidForm"), "err") return } idx, _ := strconv.Atoi(r.FormValue("index")) pattern := strings.TrimSpace(r.FormValue("pattern")) if pattern == "" { - a.redirectFlash(w, r, "/repos", "pattern 不能为空 / pattern must not be empty", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.patternRequired"), "err") return } events, err := parseEventsYAML(r.FormValue("events")) if err != nil { - a.redirectFlash(w, r, "/repos", "events YAML 解析失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.eventsParseFailed", err), "err") return } notifyTo := splitLines(r.FormValue("notify_to")) @@ -67,7 +67,7 @@ func (a *App) handleRepoSave(w http.ResponseWriter, r *http.Request) { cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.configLoadFailed"), "err") return } @@ -79,36 +79,36 @@ func (a *App) handleRepoSave(w http.ResponseWriter, r *http.Request) { } if err := SaveYAML(a.cfgDir+"/repos.yaml", cfg.Repos); err != nil { - a.redirectFlash(w, r, "/repos", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/repos", "仓库规则已保存 / repo rule saved", "ok") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.repoSaved"), "ok") } // handleRepoDelete removes a repo rule by index. func (a *App) handleRepoDelete(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/repos", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.invalidForm"), "err") return } idx, _ := strconv.Atoi(r.FormValue("index")) cfg, err := a.loadConfig() if err != nil { - a.redirectFlash(w, r, "/repos", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.configLoadFailed"), "err") return } if idx < 0 || idx >= len(cfg.Repos.Repos) { - a.redirectFlash(w, r, "/repos", "仓库不存在 / repo not found", "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.repoNotFound"), "err") return } cfg.Repos.Repos = append(cfg.Repos.Repos[:idx], cfg.Repos.Repos[idx+1:]...) if err := SaveYAML(a.cfgDir+"/repos.yaml", cfg.Repos); err != nil { - a.redirectFlash(w, r, "/repos", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/repos", "仓库规则已删除 / repo rule deleted", "ok") + a.redirectFlash(w, r, "/repos", a.message(r, "flash.repoDeleted"), "ok") } // repoListRow builds a RepoRow for list display. diff --git a/internal/panel/handlers_settings.go b/internal/panel/handlers_settings.go index 0ace4db..87f5269 100644 --- a/internal/panel/handlers_settings.go +++ b/internal/panel/handlers_settings.go @@ -38,7 +38,7 @@ func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) { // changes take effect immediately. Port/secret still require a restart. func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/settings", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.invalidForm"), "err") return } @@ -62,11 +62,11 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { passwordChanged := newPassword != "" if passwordChanged { if newPassword != confirmPassword { - a.redirectFlash(w, r, "/settings", "两次输入的新密码不一致 / new passwords do not match", "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.passwordMismatch"), "err") return } if !auth.VerifyPassword(string(currentHash), oldPassword) { - a.redirectFlash(w, r, "/settings", "旧密码错误,密码未修改 / incorrect old password", "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.incorrectOldPassword"), "err") return } } @@ -74,7 +74,7 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { // 1. Persist server.* fields (+ panel.username) in one comment-preserving write. root, err := loadServerRoot(a.cfgDir) if err != nil { - a.redirectFlash(w, r, "/settings", "读取配置失败 / failed to load config", "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.configLoadFailed"), "err") return } serverMap := ensureMap(root, "server") @@ -94,7 +94,7 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { mapSet(ensureMap(root, "panel"), "username", newUsername) } if err := writeServerRoot(a.cfgDir, root); err != nil { - a.redirectFlash(w, r, "/settings", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.saveFailed", err), "err") return } @@ -102,7 +102,7 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { // password_hash produced by the browser, so store it directly. if passwordChanged { if err := SetPanelPasswordHash(a.cfgDir, newPassword); err != nil { - a.redirectFlash(w, r, "/settings", "密码保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/settings", a.message(r, "flash.passwordSaveFailed", err), "err") return } } @@ -110,14 +110,14 @@ func (a *App) handleSettingsSave(w http.ResponseWriter, r *http.Request) { // 3. Reload so changes take effect immediately. a.notifySaved() - flash := "服务设置已保存(端口/密钥需重启生效)/ settings saved (port/secret need restart)" + flash := a.message(r, "flash.settingsSavedRestart") switch { case usernameChanged && passwordChanged: - flash = "服务设置、用户名与密码已保存 / settings, username and password saved" + flash = a.message(r, "flash.settingsUsernamePasswordSaved") case usernameChanged: - flash = "服务设置与用户名已保存 / settings and username saved" + flash = a.message(r, "flash.settingsUsernameSaved") case passwordChanged: - flash = "服务设置与密码已保存 / settings and password saved" + flash = a.message(r, "flash.settingsPasswordSaved") } a.redirectFlash(w, r, "/settings", flash, "ok") } diff --git a/internal/panel/handlers_templates.go b/internal/panel/handlers_templates.go index 1fac865..6064047 100644 --- a/internal/panel/handlers_templates.go +++ b/internal/panel/handlers_templates.go @@ -46,7 +46,7 @@ func (a *App) handleTemplateEdit(w http.ResponseWriter, r *http.Request) { root := map[string]any{} if err := loadJSONC(path, &root); err != nil { - a.redirectFlash(w, r, "/templates", "读取模板失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.templateLoadFailed", err), "err") return } @@ -66,27 +66,27 @@ func (a *App) handleTemplateEdit(w http.ResponseWriter, r *http.Request) { // alphabetically reorders keys. Functionality is preserved. func (a *App) handleTemplateSave(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { - a.redirectFlash(w, r, "/templates", "表单解析失败 / invalid form", "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.invalidForm"), "err") return } file := r.FormValue("file") event := r.FormValue("event") text := r.FormValue("payloads_json") if event == "" { - a.redirectFlash(w, r, "/templates", "未选择事件 / no event selected", "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.eventRequired"), "err") return } var payloads any if err := json.Unmarshal([]byte(text), &payloads); err != nil { - a.redirectFlash(w, r, "/templates", "payloads JSON 解析失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.payloadsParseFailed", err), "err") return } path := a.templateFilePath(file) root := map[string]any{} if err := loadJSONC(path, &root); err != nil { - a.redirectFlash(w, r, "/templates", "读取模板失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.templateLoadFailed", err), "err") return } @@ -103,11 +103,11 @@ func (a *App) handleTemplateSave(w http.ResponseWriter, r *http.Request) { templatesNode[event] = eventNode if err := SaveJSON(path, root); err != nil { - a.redirectFlash(w, r, "/templates", "保存失败: "+err.Error(), "err") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.saveFailed", err), "err") return } a.notifySaved() - a.redirectFlash(w, r, "/templates", "模板已保存(注释与格式会被重排)/ template saved (comments/format reformatted)", "ok") + a.redirectFlash(w, r, "/templates", a.message(r, "flash.templateSaved"), "ok") } // eventKeys returns the sorted event keys present in a parsed templates file. diff --git a/internal/panel/i18n.go b/internal/panel/i18n.go new file mode 100644 index 0000000..c443b48 --- /dev/null +++ b/internal/panel/i18n.go @@ -0,0 +1,72 @@ +package panel + +import ( + "embed" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +const localeCookie = "fgt_locale" + +//go:embed locales/*.json +var localesFS embed.FS + +var messages = loadMessages() + +func loadMessages() map[string]map[string]string { + locales := map[string]map[string]string{} + for _, name := range []string{"zh-CN", "en-US"} { + data, err := localesFS.ReadFile("locales/" + name + ".json") + if err != nil { + panic("read panel locale " + name + ": " + err.Error()) + } + catalog := map[string]string{} + if err := json.Unmarshal(data, &catalog); err != nil { + panic("parse panel locale " + name + ": " + err.Error()) + } + locales[name] = catalog + } + return locales +} + +func localeFrom(r *http.Request) string { + if c, err := r.Cookie(localeCookie); err == nil && validLocale(c.Value) { + return c.Value + } + if strings.HasPrefix(strings.ToLower(r.Header.Get("Accept-Language")), "en") { + return "en-US" + } + return "zh-CN" +} + +func validLocale(locale string) bool { + _, ok := messages[locale] + return ok +} + +func translate(data ViewData, key string) string { + return message(data.Locale, key) +} + +func (a *App) message(r *http.Request, key string, args ...any) string { + return fmt.Sprintf(message(localeFrom(r), key), args...) +} + +func message(locale, key string) string { + if value := messages[locale][key]; value != "" { + return value + } + if value := messages["zh-CN"][key]; value != "" { + return value + } + return key +} + +func alternateLocale(locale string) string { + if locale == "en-US" { + return "zh-CN" + } + return "en-US" +} diff --git a/internal/panel/locales/en-US.json b/internal/panel/locales/en-US.json new file mode 100644 index 0000000..f61514a --- /dev/null +++ b/internal/panel/locales/en-US.json @@ -0,0 +1,159 @@ +{ + "app.admin": "Admin panel", + "action.login": "Log in", + "action.signIn": "Sign in", + "action.logout": "Log out", + "action.language": "简体中文", + "action.save": "Save", + "action.cancel": "Cancel", + "action.new": "New", + "action.edit": "Edit", + "action.delete": "Delete", + "action.back": "Back to list", + "action.browse": "Browse events", + "action.copy": "Copy", + "action.copied": "Copied", + "nav.overview": "Overview", + "nav.repos": "Repo rules", + "nav.bots": "Feishu bots", + "nav.events": "Event sets", + "nav.templates": "Templates", + "nav.settings": "Server settings", + "nav.topology": "Topology", + "menu": "Menu", + "close": "Close", + "login.title": "Log in", + "login.description": "Enter the administrator password to open the admin panel.", + "login.username": "Username", + "login.password": "Administrator password", + "dashboard.title": "Operations overview", + "dashboard.subtitle": "Monitor notification configuration, delivery health, and relationships.", + "dashboard.repoRules": "Repo rules", + "dashboard.bots": "Feishu bots", + "dashboard.eventSets": "Event sets", + "dashboard.templates": "Template files", + "dashboard.failed": "Failed deliveries", + "dashboard.successRate": "Success rate", + "dashboard.noData": "No delivery data yet", + "dashboard.last7Days": "Delivery trend: last 7 days", + "dashboard.eventMix": "Event mix", + "dashboard.activity": "Recent activity", + "dashboard.topology": "Configuration relationships", + "dashboard.openTopology": "Open topology", + "dashboard.server": "Server status", + "dashboard.listen": "Listen address", + "dashboard.logLevel": "Log level", + "dashboard.timeout": "Timeout", + "dashboard.maxPayload": "Maximum payload", + "dashboard.allowedSources": "Allowed sources", + "topology.title": "Configuration topology", + "topology.subtitle": "Select a node to view or edit its configuration.", + "topology.empty": "No configuration relationships to show. Create a repo rule and connect events and bots first.", + "topology.legend.repo": "Repo rules", + "topology.legend.event": "Events or sets", + "topology.legend.bot": "Delivery targets", + "topology.routes": "configuration rules", + "topology.nodes": "configuration nodes", + "topology.edges": "relationships", + "topology.events": "events", + "topology.targets": "delivery targets", + "topology.column.repo": "Repo rules", + "topology.column.event": "Events and sets", + "topology.column.target": "Delivery targets", + "bots.title": "Feishu bots", + "bots.subtitle": "Bot aliases and webhook URLs can be referenced by repo rules.", + "bots.alias": "Alias", + "bots.webhookURL": "Webhook URL", + "bots.template": "Template", + "bots.empty": "No bots yet. Select New to add one.", + "bots.deleteConfirm": "Delete this bot?", + "bot.newTitle": "New bot", + "bot.editTitle": "Edit bot", + "bot.subtitle": "Repo rules use this alias to reference the bot.", + "bot.templateHint": "Optional; default is used when empty", + "repos.title": "Repo rules", + "repos.subtitle": "Repository patterns, event subscriptions, and delivery targets are evaluated in order.", + "repos.pattern": "Pattern", + "repos.events": "Events", + "repos.notifyTo": "Notify to", + "repos.eventsCount": "events", + "repos.empty": "No repo rules yet. Select New to add one.", + "repos.deleteConfirm": "Delete this repo rule?", + "repos.customSecret": "Custom secret", + "repo.newTitle": "New repo rule", + "repo.editTitle": "Edit repo rule", + "repo.subtitle": "Configure the rule's events and delivery targets.", + "repo.patternHint": "Glob patterns are supported, for example org/*", + "repo.secret": "Webhook secret", + "repo.secretHint": "Optional; uses the global server.secret when empty", + "repo.secretPlaceholder": "Empty uses the global secret", + "repo.notifyHint": "One per line", + "repo.eventsHint": "Events can reference entries in events.yaml or compose named event_sets.", + "events.title": "Event sets", + "events.subtitle": "Edit event sets and event definitions.", + "events.preserve": "Comments and formatting are preserved exactly.", + "templates.title": "Message templates", + "templates.subtitle": "Edit Feishu message templates by event.", + "templates.name": "Template name", + "templates.eventsCount": "events", + "templates.empty": "No template files were found.", + "template.editTitle": "Edit template", + "template.file": "File", + "template.event": "Event", + "template.selectEvent": "Select event", + "template.noEvents": "This file has no events.", + "template.payloads": "payloads", + "template.jsonArray": "JSON array", + "template.saveWarning": "Saving removes // comments and sorts keys alphabetically. Behavior is unchanged.", + "settings.title": "Server settings", + "settings.subtitle": "Edit server.yaml. Port and secret changes require a service restart.", + "settings.host": "Listen host", + "settings.port": "Port", + "settings.secret": "Webhook secret", + "settings.secretHint": "Global fallback. A repo rule's own secret takes precedence.", + "settings.logLevel": "Log level", + "settings.maxPayload": "Maximum payload", + "settings.timeout": "Timeout", + "settings.seconds": "seconds", + "settings.matchAll": "Match all repo rules", + "settings.matchAllHint": "By default only the first matching rule is used. When enabled, all matching rules are evaluated in order and a target receives one notification per webhook.", + "settings.allowedSources": "Allowed sources", + "settings.onePerLine": "One per line", + "settings.account": "Panel account", + "settings.adminUsername": "Administrator username", + "settings.currentPassword": "Current password", + "settings.currentPasswordHint": "Required when changing the password", + "settings.newPassword": "New password", + "settings.newPasswordHint": "Leave empty to keep the current password", + "settings.confirmPassword": "Confirm new password", + "settings.passwordHint": "Changing the password requires the current password and two matching new values.", + "settings.saveWarning": "Saving reformats server.yaml and removes comments. Port, webhook secret, and panel secret changes require a restart; other settings apply immediately.", + "settings.passwordMismatch": "The new passwords do not match.", + "settings.currentPasswordRequired": "Enter the current password to change it.", + "flash.panelLoginDisabled": "The panel administrator password is not configured.", + "flash.invalidForm": "The submitted form could not be parsed.", + "flash.invalidCredentials": "Invalid username or password.", + "flash.configLoadFailed": "Configuration could not be loaded.", + "flash.saveFailed": "Save failed: %s", + "flash.botNotFound": "The bot was not found.", + "flash.botFieldsRequired": "Alias and URL are required.", + "flash.botSaved": "Bot saved.", + "flash.botDeleted": "Bot deleted.", + "flash.repoNotFound": "The repo rule was not found.", + "flash.patternRequired": "Pattern is required.", + "flash.eventsParseFailed": "events YAML could not be parsed: %s", + "flash.repoSaved": "Repo rule saved.", + "flash.repoDeleted": "Repo rule deleted.", + "flash.eventsSaved": "Event configuration saved.", + "flash.templateLoadFailed": "Template could not be loaded: %s", + "flash.eventRequired": "Select an event first.", + "flash.payloadsParseFailed": "payloads JSON could not be parsed: %s", + "flash.templateSaved": "Template saved; comments and formatting were rewritten.", + "flash.passwordMismatch": "The new passwords do not match.", + "flash.incorrectOldPassword": "The current password is incorrect; the password was not changed.", + "flash.passwordSaveFailed": "Password save failed: %s", + "flash.settingsSavedRestart": "Server settings saved. Port and secret changes require a restart.", + "flash.settingsUsernamePasswordSaved": "Server settings, username, and password saved.", + "flash.settingsUsernameSaved": "Server settings and username saved.", + "flash.settingsPasswordSaved": "Server settings and password saved." +} diff --git a/internal/panel/locales/zh-CN.json b/internal/panel/locales/zh-CN.json new file mode 100644 index 0000000..aac316b --- /dev/null +++ b/internal/panel/locales/zh-CN.json @@ -0,0 +1,159 @@ +{ + "app.admin": "管理面板", + "action.login": "登录", + "action.signIn": "登录", + "action.logout": "退出登录", + "action.language": "English", + "action.save": "保存", + "action.cancel": "取消", + "action.new": "新建", + "action.edit": "编辑", + "action.delete": "删除", + "action.back": "返回列表", + "action.browse": "浏览事件", + "action.copy": "复制", + "action.copied": "已复制", + "nav.overview": "概览", + "nav.repos": "仓库规则", + "nav.bots": "飞书机器人", + "nav.events": "事件集合", + "nav.templates": "消息模板", + "nav.settings": "服务设置", + "nav.topology": "配置图谱", + "menu": "菜单", + "close": "关闭", + "login.title": "登录", + "login.description": "输入管理员密码以进入管理面板。", + "login.username": "用户名", + "login.password": "管理员密码", + "dashboard.title": "运行概览", + "dashboard.subtitle": "查看通知配置、投递健康度与配置关系。", + "dashboard.repoRules": "仓库规则", + "dashboard.bots": "飞书机器人", + "dashboard.eventSets": "事件集合", + "dashboard.templates": "模板文件", + "dashboard.failed": "投递失败", + "dashboard.successRate": "成功率", + "dashboard.noData": "暂无投递数据", + "dashboard.last7Days": "最近 7 天投递趋势", + "dashboard.eventMix": "事件分布", + "dashboard.activity": "最近活动", + "dashboard.topology": "配置关系", + "dashboard.openTopology": "打开图谱", + "dashboard.server": "服务状态", + "dashboard.listen": "监听地址", + "dashboard.logLevel": "日志级别", + "dashboard.timeout": "超时", + "dashboard.maxPayload": "最大载荷", + "dashboard.allowedSources": "允许来源", + "topology.title": "配置图谱", + "topology.subtitle": "选择节点查看或修改对应配置。", + "topology.empty": "暂无可展示的配置关系。先创建仓库规则并关联事件和机器人。", + "topology.legend.repo": "仓库规则", + "topology.legend.event": "事件或集合", + "topology.legend.bot": "通知目标", + "topology.routes": "条配置规则", + "topology.nodes": "个配置节点", + "topology.edges": "条关联", + "topology.events": "个事件", + "topology.targets": "个通知目标", + "topology.column.repo": "仓库规则", + "topology.column.event": "事件与集合", + "topology.column.target": "通知目标", + "bots.title": "飞书机器人", + "bots.subtitle": "机器人别名与 Webhook URL,可在仓库规则中通过别名引用。", + "bots.alias": "别名", + "bots.webhookURL": "Webhook URL", + "bots.template": "模板", + "bots.empty": "暂无机器人,点击“新建”添加。", + "bots.deleteConfirm": "删除该机器人?", + "bot.newTitle": "新建机器人", + "bot.editTitle": "编辑机器人", + "bot.subtitle": "别名用于在仓库规则中引用此机器人。", + "bot.templateHint": "可选,默认使用 default", + "repos.title": "仓库规则", + "repos.subtitle": "仓库匹配模式、订阅事件与通知目标按配置顺序匹配。", + "repos.pattern": "模式", + "repos.events": "事件", + "repos.notifyTo": "通知目标", + "repos.eventsCount": "个事件", + "repos.empty": "暂无仓库规则,点击“新建”添加。", + "repos.deleteConfirm": "删除该仓库规则?", + "repos.customSecret": "自定义密钥", + "repo.newTitle": "新建仓库规则", + "repo.editTitle": "编辑仓库规则", + "repo.subtitle": "设置规则的事件与通知目标。", + "repo.patternHint": "支持 glob,例如 org/*", + "repo.secret": "Webhook 密钥", + "repo.secretHint": "可选;留空则使用全局 server.secret", + "repo.secretPlaceholder": "留空则使用全局密钥", + "repo.notifyHint": "每行一个", + "repo.eventsHint": "事件可直接引用 events.yaml 中的事件,也可叠加 event_sets 中的模板名称。", + "events.title": "事件集合", + "events.subtitle": "编辑事件集合和事件定义。", + "events.preserve": "注释与格式会完整保留。", + "templates.title": "消息模板", + "templates.subtitle": "按事件编辑飞书消息模板。", + "templates.name": "模板名", + "templates.eventsCount": "个事件", + "templates.empty": "未发现模板文件。", + "template.editTitle": "编辑模板", + "template.file": "文件", + "template.event": "事件", + "template.selectEvent": "选择事件", + "template.noEvents": "该文件没有事件。", + "template.payloads": "的 payloads", + "template.jsonArray": "JSON 数组", + "template.saveWarning": "保存会移除该文件中的 // 注释,并按字母顺序重排键;功能不变。", + "settings.title": "服务设置", + "settings.subtitle": "编辑 server.yaml。端口和密钥的修改需要重启服务后生效。", + "settings.host": "监听主机", + "settings.port": "端口", + "settings.secret": "Webhook 密钥", + "settings.secretHint": "全局回退密钥。若规则设置了单独密钥,则优先使用规则密钥。", + "settings.logLevel": "日志级别", + "settings.maxPayload": "最大载荷", + "settings.timeout": "超时", + "settings.seconds": "秒", + "settings.matchAll": "匹配全部仓库规则", + "settings.matchAllHint": "默认只使用首条匹配规则。开启后会按顺序评估全部匹配规则;同一目标在一次 webhook 中只发送一次。", + "settings.allowedSources": "允许来源", + "settings.onePerLine": "每行一个", + "settings.account": "面板账号", + "settings.adminUsername": "管理员用户名", + "settings.currentPassword": "当前密码", + "settings.currentPasswordHint": "修改密码时必填", + "settings.newPassword": "新密码", + "settings.newPasswordHint": "留空则不修改", + "settings.confirmPassword": "确认新密码", + "settings.passwordHint": "修改密码需提供当前密码,并输入两次相同的新密码。", + "settings.saveWarning": "保存会重新格式化 server.yaml 并移除注释。端口、密钥和面板密钥的修改需重启服务;其它设置会立即生效。", + "settings.passwordMismatch": "两次输入的新密码不一致。", + "settings.currentPasswordRequired": "修改密码需填写当前密码。", + "flash.panelLoginDisabled": "面板未配置管理员密码。", + "flash.invalidForm": "表单解析失败。", + "flash.invalidCredentials": "用户名或密码错误。", + "flash.configLoadFailed": "读取配置失败。", + "flash.saveFailed": "保存失败:%s", + "flash.botNotFound": "机器人不存在。", + "flash.botFieldsRequired": "别名和 URL 不能为空。", + "flash.botSaved": "机器人已保存。", + "flash.botDeleted": "机器人已删除。", + "flash.repoNotFound": "仓库规则不存在。", + "flash.patternRequired": "模式不能为空。", + "flash.eventsParseFailed": "events YAML 解析失败:%s", + "flash.repoSaved": "仓库规则已保存。", + "flash.repoDeleted": "仓库规则已删除。", + "flash.eventsSaved": "事件配置已保存。", + "flash.templateLoadFailed": "读取模板失败:%s", + "flash.eventRequired": "未选择事件。", + "flash.payloadsParseFailed": "payloads JSON 解析失败:%s", + "flash.templateSaved": "模板已保存;注释与格式会被重排。", + "flash.passwordMismatch": "两次输入的新密码不一致。", + "flash.incorrectOldPassword": "旧密码错误,密码未修改。", + "flash.passwordSaveFailed": "密码保存失败:%s", + "flash.settingsSavedRestart": "服务设置已保存;端口和密钥的修改需重启后生效。", + "flash.settingsUsernamePasswordSaved": "服务设置、用户名和密码已保存。", + "flash.settingsUsernameSaved": "服务设置和用户名已保存。", + "flash.settingsPasswordSaved": "服务设置和密码已保存。" +} diff --git a/internal/panel/templates/bot_edit.html b/internal/panel/templates/bot_edit.html index 98db61c..22b6fa0 100644 --- a/internal/panel/templates/bot_edit.html +++ b/internal/panel/templates/bot_edit.html @@ -1,20 +1,20 @@ -{{define "title"}}编辑机器人 · Feishu Bot{{end}} +{{define "title"}}{{if lt .EditBot.Index 0}}{{t . "bot.newTitle"}}{{else}}{{t . "bot.editTitle"}}{{end}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

{{if lt .EditBot.Index 0}}新建机器人 / New Bot{{else}}编辑机器人 / Edit Bot{{end}}

-
别名用于在仓库规则中引用此机器人。 / The alias is how repo rules reference this bot.
+

{{if lt .EditBot.Index 0}}{{t . "bot.newTitle"}}{{else}}{{t . "bot.editTitle"}}{{end}}

+
{{t . "bot.subtitle"}}
- + - + - +
- - 取消 / Cancel + + {{t . "action.cancel"}}
{{end}} diff --git a/internal/panel/templates/bots.html b/internal/panel/templates/bots.html index a1d5e87..d6ef7e1 100644 --- a/internal/panel/templates/bots.html +++ b/internal/panel/templates/bots.html @@ -1,11 +1,11 @@ -{{define "title"}}飞书机器人 · Feishu Bots{{end}} +{{define "title"}}{{t . "bots.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

飞书机器人 / Feishu Bots

-
机器人别名与 Webhook URL,在仓库规则中以 alias 引用。 / Bot aliases + webhook URLs, referenced by alias in repo rules.
+

{{t . "bots.title"}}

+
{{t . "bots.subtitle"}}
- + 新建 / New + + {{t . "action.new"}}
@@ -14,9 +14,9 @@

飞书机器人 / Feishu Bots

# - 别名 / Alias - Webhook URL - 模板 / Template + {{t . "bots.alias"}} + {{t . "bots.webhookURL"}} + {{t . "bots.template"}} @@ -29,10 +29,10 @@

飞书机器人 / Feishu Bots

{{if .Template}}{{.Template}}{{else}}default{{end}}
- 编辑 / Edit + {{t $ "action.edit"}}
- +
@@ -41,7 +41,7 @@

飞书机器人 / Feishu Bots

{{else}} -
暂无机器人,点击「新建」添加。 / No bots yet — click “New”.
+
{{t . "bots.empty"}}
{{end}}
{{end}} diff --git a/internal/panel/templates/dashboard.html b/internal/panel/templates/dashboard.html index 389de87..59daa95 100644 --- a/internal/panel/templates/dashboard.html +++ b/internal/panel/templates/dashboard.html @@ -1,100 +1,77 @@ -{{define "title"}}仪表盘 · Dashboard{{end}} +{{define "title"}}{{t . "dashboard.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

仪表盘 / Dashboard

-
配置概览与最近投递活动。 / Configuration overview and recent delivery activity.
+

{{t . "dashboard.title"}}

+
{{t . "dashboard.subtitle"}}
-
-
-
{{.RepoCount}}
-
仓库规则 / Repo rules
-
-
-
{{.BotCount}}
-
飞书机器人 / Feishu bots
-
-
-
{{.EventSetCount}}
-
事件集合 / Event sets
-
-
-
{{len .TemplateFiles}}
-
模板文件 / Template files
-
+
+
{{.RepoCount}}
{{t . "dashboard.repoRules"}}
+
{{.BotCount}}
{{t . "dashboard.bots"}}
+
{{.EventSetCount}}
{{t . "dashboard.eventSets"}}
+
{{len .TemplateFiles}}
{{t . "dashboard.templates"}}
+
{{.Delivery.Failed}}
{{t . "dashboard.failed"}}
+
{{if .Delivery.Total}}{{.Delivery.SuccessRate}}%{{else}}—{{end}}
{{t . "dashboard.successRate"}}
-
-

快速上手 / Getting started

+
+
+
{{t . "dashboard.last7Days"}}{{.Delivery.Total}}
+ {{if .Delivery.Total}} +
+ {{range .Delivery.Days}} +
+ + + +
+ {{end}} +
+ {{else}}
{{t . "dashboard.noData"}}
{{end}} +
-
- 1. 添加飞书机器人 / Add a Feishu bot -
    -
  1. 飞书群 → 设置 → 群机器人 → 添加「自定义机器人」,复制其 Webhook URL。
  2. -
  3. 飞书机器人 / Feishu Bots 页面新增:填入别名与该 URL。
  4. -
-
+
+
{{t . "dashboard.eventMix"}}
+ {{if .Delivery.Events}} +
+ {{range .Delivery.Events}} +
{{.Label}}
{{.Count}}
+ {{end}} +
+ {{else}}
{{t . "dashboard.noData"}}
{{end}} +
-
- 2. 添加 GitHub Webhook / Add a GitHub webhook -
    -
  1. GitHub 仓库 → SettingsWebhooksAdd webhook
  2. -
  3. Payload URL 填下方地址;Content type 选什么都支持;Secret 填你在 server.yaml 或对应仓库规则里配置的 secret(若有)。 - {{if .PayloadURL}} -
    - {{.PayloadURL}} - -
    - {{else}} -
    当前通过本地地址(localhost / 内网 IP)访问,无法生成可被 GitHub 访问的 Payload URL。请通过公网域名 / 公网 IP 访问本面板,或在 server.yaml 中配置 panel.public_url。 / Accessed locally — open via a public host, or set panel.public_url.
    - {{end}} -
  4. -
  5. 选择要监听的事件并保存,再到 仓库规则 / Repos 配置监听与通知目标。
  6. -
-
-
+
+
{{t . "dashboard.activity"}}
+ {{if .Delivery.Recent}} +
{{range .Delivery.Recent}}
{{.Time}}{{.Target}}
{{end}}
+ {{else}}
{{t . "dashboard.noData"}}
{{end}} +
-
-
-

服务信息 / Server

-
-
监听 / Listen
-
{{.ServerInfo.Host}}:{{.ServerInfo.Port}}
-
日志级别 / Log level
-
{{if .ServerInfo.LogLevel}}{{.ServerInfo.LogLevel}}{{else}}—{{end}}
-
超时 / Timeout
-
{{if .ServerInfo.Timeout}}{{.ServerInfo.Timeout}}s{{else}}—{{end}}
-
最大载荷 / Max payload
-
{{if .ServerInfo.MaxPayloadSize}}{{.ServerInfo.MaxPayloadSize}}{{else}}—{{end}}
-
允许来源 / Allowed
-
- {{if .ServerInfo.AllowedSources}}{{range .ServerInfo.AllowedSources}}{{.}}{{end}}{{else}}—{{end}} +
+
{{t . "dashboard.topology"}}{{t . "dashboard.openTopology"}}
+ {{if .Topology.Routes}} + -
- -
-

模板文件 / Template files

- {{if .TemplateFiles}} -
- {{range .TemplateFiles}}{{.}}{{end}} -
- {{else}} -
无模板文件 / none
- {{end}} -
在「消息模板 / Templates」页面编辑各事件卡片。 / Edit per-event cards under Templates.
-
+ {{else}}
{{t . "topology.empty"}}
{{end}} +
-
-

最近投递 / Recent deliveries

-
来自日志文件尾部(只读)。 / Tailed from the log file (read-only).
- {{if .RecentLines}} -
- {{range .RecentLines}}
{{.}}
{{end}} +
+
{{t . "dashboard.server"}}
+
+
{{t . "dashboard.listen"}}
{{.ServerInfo.Host}}:{{.ServerInfo.Port}}
+
{{t . "dashboard.logLevel"}}
{{if .ServerInfo.LogLevel}}{{.ServerInfo.LogLevel}}{{else}}—{{end}}
+
{{t . "dashboard.timeout"}}
{{if .ServerInfo.Timeout}}{{.ServerInfo.Timeout}}s{{else}}—{{end}}
+
{{t . "dashboard.maxPayload"}}
{{if .ServerInfo.MaxPayloadSize}}{{.ServerInfo.MaxPayloadSize}}{{else}}—{{end}}
+
{{t . "dashboard.allowedSources"}}
{{range .ServerInfo.AllowedSources}}{{.}}{{else}}—{{end}}
- {{else}} -
暂无投递记录 / no recent activity
- {{end}} -
+ {{end}} diff --git a/internal/panel/templates/events.html b/internal/panel/templates/events.html index edb1e5a..b571bb4 100644 --- a/internal/panel/templates/events.html +++ b/internal/panel/templates/events.html @@ -1,16 +1,16 @@ -{{define "title"}}事件 · Events{{end}} +{{define "title"}}{{t . "events.title"}} · Feishu GitHub Tracker{{end}} {{define "content"}}
-

事件 / Events

-
编辑 events.yaml 原文(event_setsevents)。保存时会校验 YAML 语法。 / Edit the raw events.yaml; YAML is validated on save.
+

{{t . "events.title"}}

+
{{t . "events.subtitle"}}
-
注释与格式将被完整保留。 / Comments and formatting are preserved verbatim.
+
{{t . "events.preserve"}}
- +
{{end}} diff --git a/internal/panel/templates/layout.html b/internal/panel/templates/layout.html index 958af30..6eb1a7b 100644 --- a/internal/panel/templates/layout.html +++ b/internal/panel/templates/layout.html @@ -2,32 +2,33 @@ {{end}} {{define "layout"}} - + @@ -690,10 +691,46 @@ flex: 1 1 100%; } } + /* Operations workspace: navigation stays quiet while tools sit on a + separate, lightly elevated canvas. */ + body { background:#edf3f8; } + .sidebar { width:236px; flex-basis:236px; background:#f6f9fd; border-right:0; } + .main { padding:10px 12px 12px; background:#edf3f8; } + .wrap { max-width:none; margin:0; } + .topbar { min-height:36px; margin:0 8px 8px; } + .workspace { min-width:0; min-height:calc(100vh - 66px); padding:24px; border:1px solid #dce6f0; border-radius:16px; background:#fff; } + .sidebar .brand { margin:0 2px 12px; padding:12px 10px 16px; } + .nav a { margin:2px 0; padding:10px 12px; border-radius:9px; } + .nav a small { display:none; } + .card, .stat { border-radius:10px; } + .languageLink { padding:7px 10px; border:1px solid var(--border); border-radius:999px; background:var(--bg); color:var(--muted); font-size:12px; } + .languageLink:hover { color:var(--accent-strong); text-decoration:none; } + .dashboardGrid { display:grid; grid-template-columns:minmax(0,1.55fr) minmax(330px,.85fr); gap:14px; } + .metricsGrid { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:12px; } + .metricTile { min-height:98px; padding:16px; border:1px solid var(--border); border-radius:10px; background:linear-gradient(145deg,#fff,#f7fbff); box-shadow:0 5px 14px rgba(43,71,106,.05); } + .metricTile:hover { border-color:rgba(78,172,248,.55); text-decoration:none; transform:translateY(-1px); } + .metricValue { color:var(--text); font-size:27px; font-weight:720; line-height:1; } + .metricLabel { margin-top:8px; color:var(--muted); font-size:12px; } + .metricTile.warn .metricValue { color:var(--danger); }.metricTile.ok .metricValue { color:#159563; } + .chartCard { padding:18px; border:1px solid var(--border); border-radius:10px; background:#fff; box-shadow:0 4px 12px rgba(43,71,106,.035); } + .chartTitle { display:flex; justify-content:space-between; gap:12px; margin-bottom:14px; font-size:14px; font-weight:650; } + .deliveryChart { display:flex; align-items:flex-end; gap:8px; height:152px; padding:0 2px 18px; border-bottom:1px solid var(--border); } + .deliveryDay { position:relative; display:flex; flex:1 1 0; align-items:flex-end; justify-content:center; gap:3px; height:100%; min-width:20px; } + .deliveryBar { width:min(18px,42%); min-height:2px; border-radius:5px 5px 2px 2px; background:#24a9e8; }.deliveryBar.failed { background:#ef6d72; } + .deliveryDay label { position:absolute; bottom:-20px; margin:0; color:var(--muted); font-size:10px; font-weight:500; } + .eventBars { display:grid; gap:10px; }.eventBar { display:grid; grid-template-columns:minmax(74px,1fr) minmax(0,2.5fr) 22px; align-items:center; gap:8px; font-size:12px; } + .eventBar > span:first-child { overflow:hidden; color:var(--muted); text-overflow:ellipsis; white-space:nowrap; }.eventTrack { height:7px; overflow:hidden; border-radius:99px; background:#edf3f8; }.eventTrack i { display:block; height:100%; border-radius:inherit; background:#7869ea; } + .activityList { display:grid; gap:2px; }.activityRow { display:grid; grid-template-columns:76px minmax(0,1fr) auto; align-items:center; gap:10px; padding:10px 0; border-bottom:1px solid var(--border); font-size:12px; }.activityRow:last-child { border-bottom:0; }.activityTarget { overflow:hidden; color:var(--text); text-overflow:ellipsis; white-space:nowrap; } + .statusDot { width:8px; height:8px; border-radius:50%; background:#17ad75; }.statusDot.failed { background:#ee5f67; } + .topologyPreview { display:grid; align-content:start; gap:9px; }.topologyStep { display:grid; grid-template-columns:minmax(0,1fr) 22px minmax(0,1.1fr); align-items:center; gap:8px; }.topologyStep .arrow { color:var(--accent-strong); text-align:center; }.topologyNode { display:block; overflow:hidden; padding:10px 11px; border:1px solid var(--border); border-left:3px solid var(--accent); border-radius:8px; background:var(--bg); color:var(--text); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }.topologyNode:hover { border-color:var(--accent); text-decoration:none; }.topologyNode.event { border-left-color:#7869ea; }.topologyNode.bot { border-left-color:#16a870; } + .topologyStats { display:flex; gap:0; width:max-content; max-width:100%; margin:4px 0 16px; overflow:hidden; border:1px solid var(--border); border-radius:10px; background:#fbfdff; }.topologyStats div { display:flex; align-items:baseline; gap:7px; padding:10px 18px; border-right:1px solid var(--border); }.topologyStats div:last-child { border-right:0; }.topologyStats strong { color:var(--accent-strong); font-size:20px; }.topologyStats span { color:var(--muted); font-size:12px; } + .topologyCanvas { min-height:620px; overflow:auto; border:1px solid var(--border); border-radius:12px; background:radial-gradient(circle at 1px 1px,#dfe9f3 1px,transparent 0) 0 0/18px 18px,#fbfdff; }.topologyCanvas svg { display:block; width:100%; height:620px; min-width:1240px; }.topologyLegend { display:flex; flex-wrap:wrap; gap:14px; margin:14px 0; color:var(--muted); font-size:12px; }.legendMark { display:inline-block; width:9px; height:9px; margin-right:5px; border-radius:3px; background:var(--accent); }.legendMark.event { background:#7869ea; }.legendMark.bot { background:#16a870; } + @media (max-width:1280px) { .metricsGrid { grid-template-columns:repeat(3,minmax(0,1fr)); } } + @media (max-width:920px) { .main { padding:0; }.workspace { min-height:0; padding:16px; border:0; border-radius:0; }.dashboardGrid, .metricsGrid { grid-template-columns:1fr; }.topologyStats { width:100%; }.topologyStats div { flex:1; padding:9px; flex-direction:column; gap:1px; }.topologyCanvas { margin:0 -16px; border-radius:0; } } - + {{if .HideNav}}
@@ -717,7 +754,7 @@
{{if .Authed}}
- +
{{end}} @@ -726,7 +763,7 @@