From 5e4355700ff2729f88bd43f7c2104e02d1a7a33a Mon Sep 17 00:00:00 2001 From: QWQLwToo <2467013926@qq.com> Date: Sun, 12 Jul 2026 09:12:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/config/branding.go | 57 +- .../internal/config/config.go | 48 ++ .../internal/db/audit_store.go | 83 +++ .../internal/db/source_store.go | 61 ++ .../internal/sources/sources.go | 45 +- .../internal/web/admin_system_routes.go | 34 +- .../internal/web/router_test.go | 16 +- .../web/admin/package-lock.json | 93 +++ .../unified-management/web/admin/package.json | 3 + .../unified-management/web/admin/src/App.vue | 155 ++++- .../web/admin/src/branding.ts | 59 ++ .../unified-management/web/admin/src/main.ts | 28 +- .../web/admin/src/stores/system.ts | 6 +- .../web/admin/src/styles.css | 49 +- .../web/admin/src/views/DashboardView.vue | 147 +++-- .../web/admin/src/views/EndpointsView.vue | 56 +- .../web/admin/src/views/SourcesView.vue | 38 +- .../web/admin/src/views/SystemView.vue | 64 +- .../web/portal/package-lock.json | 93 +++ .../web/portal/package.json | 3 + .../unified-management/web/portal/src/App.vue | 48 +- .../web/portal/src/branding.ts | 59 ++ .../unified-management/web/portal/src/main.ts | 37 +- .../portal/src/pages/CompatibilityPage.vue | 19 +- .../web/portal/src/pages/FeedbackPage.vue | 110 +++- .../web/portal/src/pages/OverviewPage.vue | 98 +-- .../web/portal/src/pages/ReleasesPage.vue | 142 +++- .../web/portal/src/pages/SourcesPage.vue | 117 +++- .../web/portal/src/state.ts | 136 +++- .../web/portal/src/styles.css | 618 ++++++++---------- .../web/setup/package-lock.json | 93 +++ .../unified-management/web/setup/package.json | 3 + .../unified-management/web/setup/src/main.ts | 21 +- server/update/public/update-info.json | 18 +- .../Updates/UpdateNoticeDocument.cs | 476 ++++++++++++++ .../UpdateNoticeDocumentTests.cs | 79 +++ src/box-winUI/Controls/AppShell.cs | 4 +- src/box-winUI/MainWindow.xaml.cs | 6 +- .../Services/AppInstallerUpdateService.cs | 4 +- .../Services/IAppInstallerUpdateService.cs | 13 +- src/box-winUI/UpdateNoticeRenderer.cs | 208 ++++++ src/box-winUI/Views/AboutPage.cs | 22 +- src/box-winUI/Views/HomePage.cs | 37 +- 43 files changed, 2797 insertions(+), 709 deletions(-) create mode 100644 server/unified-management/web/admin/src/branding.ts create mode 100644 server/unified-management/web/portal/src/branding.ts create mode 100644 src/YMhut.Box.Core/Updates/UpdateNoticeDocument.cs create mode 100644 src/YMhut.Box.Tests/UpdateNoticeDocumentTests.cs create mode 100644 src/box-winUI/UpdateNoticeRenderer.cs diff --git a/server/unified-management/internal/config/branding.go b/server/unified-management/internal/config/branding.go index 219d69b..318b438 100644 --- a/server/unified-management/internal/config/branding.go +++ b/server/unified-management/internal/config/branding.go @@ -3,26 +3,57 @@ package config import "strings" type SafeBrandingConfig struct { + SiteName string `json:"siteName"` + PortalTitle string `json:"portalTitle"` + PortalSubtitle string `json:"portalSubtitle"` + AdminTitle string `json:"adminTitle"` + AdminSubtitle string `json:"adminSubtitle"` SiteIconURL string `json:"siteIconUrl"` + LogoURL string `json:"logoUrl"` DeveloperAvatarURL string `json:"developerAvatarUrl"` DeveloperName string `json:"developerName"` FeedbackEmail string `json:"feedbackEmail"` } func SafeBranding(cfg BrandingConfig) SafeBrandingConfig { + normalized := NormalizeBranding(BrandingConfig{}, cfg) return SafeBrandingConfig{ - SiteIconURL: strings.TrimSpace(cfg.SiteIconURL), - DeveloperAvatarURL: strings.TrimSpace(cfg.DeveloperAvatarURL), - DeveloperName: strings.TrimSpace(firstNonEmpty(cfg.DeveloperName, "YMhut")), - FeedbackEmail: strings.TrimSpace(firstNonEmpty(cfg.FeedbackEmail, "support@ymhut.cn")), + SiteName: strings.TrimSpace(normalized.SiteName), + PortalTitle: strings.TrimSpace(normalized.PortalTitle), + PortalSubtitle: strings.TrimSpace(normalized.PortalSubtitle), + AdminTitle: strings.TrimSpace(normalized.AdminTitle), + AdminSubtitle: strings.TrimSpace(normalized.AdminSubtitle), + SiteIconURL: strings.TrimSpace(normalized.SiteIconURL), + LogoURL: strings.TrimSpace(normalized.LogoURL), + DeveloperAvatarURL: strings.TrimSpace(normalized.DeveloperAvatarURL), + DeveloperName: strings.TrimSpace(normalized.DeveloperName), + FeedbackEmail: strings.TrimSpace(normalized.FeedbackEmail), } } func NormalizeBranding(current BrandingConfig, incoming BrandingConfig) BrandingConfig { next := current + if value := strings.TrimSpace(incoming.SiteName); value != "" { + next.SiteName = value + } + if value := strings.TrimSpace(incoming.PortalTitle); value != "" { + next.PortalTitle = value + } + if value := strings.TrimSpace(incoming.PortalSubtitle); value != "" { + next.PortalSubtitle = value + } + if value := strings.TrimSpace(incoming.AdminTitle); value != "" { + next.AdminTitle = value + } + if value := strings.TrimSpace(incoming.AdminSubtitle); value != "" { + next.AdminSubtitle = value + } if value := strings.TrimSpace(incoming.SiteIconURL); value != "" { next.SiteIconURL = value } + if value := strings.TrimSpace(incoming.LogoURL); value != "" { + next.LogoURL = value + } if value := strings.TrimSpace(incoming.DeveloperAvatarURL); value != "" { next.DeveloperAvatarURL = value } @@ -32,9 +63,27 @@ func NormalizeBranding(current BrandingConfig, incoming BrandingConfig) Branding if value := strings.TrimSpace(incoming.FeedbackEmail); value != "" { next.FeedbackEmail = value } + if next.SiteName == "" { + next.SiteName = "YMhut Box" + } + if next.PortalTitle == "" { + next.PortalTitle = next.SiteName + " 统一服务门户" + } + if next.PortalSubtitle == "" { + next.PortalSubtitle = "统一发布、反馈与接口源状态门户" + } + if next.AdminTitle == "" { + next.AdminTitle = "YMhut 统一管理台" + } + if next.AdminSubtitle == "" { + next.AdminSubtitle = "发布、反馈、接口源与系统运维" + } if next.SiteIconURL == "" { next.SiteIconURL = "/assets/favicon.ico" } + if next.LogoURL == "" { + next.LogoURL = next.SiteIconURL + } if next.DeveloperAvatarURL == "" { next.DeveloperAvatarURL = "/assets/developer-avatar.png" } diff --git a/server/unified-management/internal/config/config.go b/server/unified-management/internal/config/config.go index 4574c31..caafe36 100644 --- a/server/unified-management/internal/config/config.go +++ b/server/unified-management/internal/config/config.go @@ -72,7 +72,13 @@ type MailConfig struct { } type BrandingConfig struct { + SiteName string `json:"site_name"` + PortalTitle string `json:"portal_title"` + PortalSubtitle string `json:"portal_subtitle"` + AdminTitle string `json:"admin_title"` + AdminSubtitle string `json:"admin_subtitle"` SiteIconURL string `json:"site_icon_url"` + LogoURL string `json:"logo_url"` DeveloperAvatarURL string `json:"developer_avatar_url"` DeveloperName string `json:"developer_name"` FeedbackEmail string `json:"feedback_email"` @@ -163,7 +169,13 @@ func defaults(root string) *Config { TimeoutSeconds: 20, }, Branding: BrandingConfig{ + SiteName: "YMhut Box", + PortalTitle: "YMhut Box 统一服务门户", + PortalSubtitle: "统一发布、反馈与接口源状态门户", + AdminTitle: "YMhut 统一管理台", + AdminSubtitle: "发布、反馈、接口源与系统运维", SiteIconURL: "/assets/favicon.ico", + LogoURL: "/assets/favicon.ico", DeveloperAvatarURL: "/assets/developer-avatar.png", DeveloperName: "YMhut", FeedbackEmail: "support@ymhut.cn", @@ -276,6 +288,24 @@ func applyEnv(cfg *Config) { if value := os.Getenv("YMHUT_BRAND_ICON_URL"); value != "" { cfg.Branding.SiteIconURL = value } + if value := os.Getenv("YMHUT_BRAND_SITE_NAME"); value != "" { + cfg.Branding.SiteName = value + } + if value := os.Getenv("YMHUT_BRAND_PORTAL_TITLE"); value != "" { + cfg.Branding.PortalTitle = value + } + if value := os.Getenv("YMHUT_BRAND_PORTAL_SUBTITLE"); value != "" { + cfg.Branding.PortalSubtitle = value + } + if value := os.Getenv("YMHUT_BRAND_ADMIN_TITLE"); value != "" { + cfg.Branding.AdminTitle = value + } + if value := os.Getenv("YMHUT_BRAND_ADMIN_SUBTITLE"); value != "" { + cfg.Branding.AdminSubtitle = value + } + if value := os.Getenv("YMHUT_BRAND_LOGO_URL"); value != "" { + cfg.Branding.LogoURL = value + } if value := os.Getenv("YMHUT_BRAND_DEVELOPER_AVATAR_URL"); value != "" { cfg.Branding.DeveloperAvatarURL = value } @@ -453,9 +483,27 @@ func normalize(root string, cfg *Config) { if cfg.Mail.TimeoutSeconds <= 0 { cfg.Mail.TimeoutSeconds = 20 } + if cfg.Branding.SiteName == "" { + cfg.Branding.SiteName = "YMhut Box" + } + if cfg.Branding.PortalTitle == "" { + cfg.Branding.PortalTitle = cfg.Branding.SiteName + " 统一服务门户" + } + if cfg.Branding.PortalSubtitle == "" { + cfg.Branding.PortalSubtitle = "统一发布、反馈与接口源状态门户" + } + if cfg.Branding.AdminTitle == "" { + cfg.Branding.AdminTitle = "YMhut 统一管理台" + } + if cfg.Branding.AdminSubtitle == "" { + cfg.Branding.AdminSubtitle = "发布、反馈、接口源与系统运维" + } if cfg.Branding.SiteIconURL == "" { cfg.Branding.SiteIconURL = "/assets/favicon.ico" } + if cfg.Branding.LogoURL == "" { + cfg.Branding.LogoURL = cfg.Branding.SiteIconURL + } if cfg.Branding.DeveloperAvatarURL == "" { cfg.Branding.DeveloperAvatarURL = "/assets/developer-avatar.png" } diff --git a/server/unified-management/internal/db/audit_store.go b/server/unified-management/internal/db/audit_store.go index 6e9049a..7475a73 100644 --- a/server/unified-management/internal/db/audit_store.go +++ b/server/unified-management/internal/db/audit_store.go @@ -20,6 +20,7 @@ func (s *Store) DashboardOverview(limit int) (map[string]any, error) { healthCounts, _ := s.groupCounts("source_endpoints", "last_status") recentChecks, _ := s.RecentSourceChecks(limit) recentCalls, _ := s.RecentSourceCalls(limit) + averageLatency, _ := s.AverageSourceLatencyBuckets(limit) audit, _ := s.ListAuditLogs(10) return map[string]any{ "ok": true, @@ -34,12 +35,94 @@ func (s *Store) DashboardOverview(limit int) (map[string]any, error) { "feedbackStatus": statusCounts, "sourceHealth": healthCounts, "heartbeats": recentChecks, + "averageLatency": averageLatency, "clientCalls": recentCalls, "database": s.Status(), "audit": audit, }, nil } +func (s *Store) AverageSourceLatencyBuckets(limit int) ([]map[string]any, error) { + if limit <= 0 || limit > 200 { + limit = 80 + } + rows, err := s.query(`SELECT checked_at, latency_ms, status FROM endpoint_health_checks ORDER BY checked_at DESC, id DESC LIMIT ?`, limit*4) + if err != nil { + return nil, err + } + defer rows.Close() + type bucket struct { + label string + total int + count int + ok int + latest string + } + order := []string{} + buckets := map[string]*bucket{} + for rows.Next() { + var checkedAt, status string + var latency int + if err := rows.Scan(&checkedAt, &latency, &status); err != nil { + return nil, err + } + label := latencyBucketLabel(checkedAt) + if label == "" { + label = checkedAt + } + item, ok := buckets[label] + if !ok { + item = &bucket{label: label, latest: checkedAt} + buckets[label] = item + order = append(order, label) + } + item.total += latency + item.count++ + if status == "ok" || status == "redirected" { + item.ok++ + } + if checkedAt > item.latest { + item.latest = checkedAt + } + } + if err := rows.Err(); err != nil { + return nil, err + } + out := []map[string]any{} + for i := len(order) - 1; i >= 0; i-- { + item := buckets[order[i]] + if item == nil || item.count == 0 { + continue + } + out = append(out, map[string]any{ + "label": item.label, + "averageLatency": item.total / item.count, + "avgLatencyMs": item.total / item.count, + "sampleCount": item.count, + "healthyCount": item.ok, + "checkedAt": item.latest, + }) + } + if len(out) > limit { + out = out[len(out)-limit:] + } + return out, nil +} + +func latencyBucketLabel(value string) string { + if value == "" { + return "" + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + if len(value) >= 16 { + return value[:16] + } + return value + } + return parsed.UTC().Format("01-02 15:04") +} + func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) { rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id diff --git a/server/unified-management/internal/db/source_store.go b/server/unified-management/internal/db/source_store.go index 84c3d86..4f1d917 100644 --- a/server/unified-management/internal/db/source_store.go +++ b/server/unified-management/internal/db/source_store.go @@ -3,6 +3,8 @@ package db import ( "database/sql" "errors" + "fmt" + "strings" ) func (s *Store) UpsertSource(item Source) (Source, error) { @@ -165,6 +167,65 @@ func (s *Store) RecordSourceCheck(sourceDBID int64, status string, latency int, return err } +func (s *Store) SourceHealthHistory(sourceDBIDs []int64, limit int) (map[int64][]map[string]any, error) { + if len(sourceDBIDs) == 0 { + return map[int64][]map[string]any{}, nil + } + if limit <= 0 || limit > 48 { + limit = 16 + } + placeholders := make([]string, 0, len(sourceDBIDs)) + args := make([]any, 0, len(sourceDBIDs)+1) + for _, id := range sourceDBIDs { + if id <= 0 { + continue + } + placeholders = append(placeholders, "?") + args = append(args, id) + } + if len(placeholders) == 0 { + return map[int64][]map[string]any{}, nil + } + args = append(args, limit*len(placeholders)) + rows, err := s.query(fmt.Sprintf(`SELECT source_db_id, status, latency_ms, checked_at + FROM endpoint_health_checks + WHERE source_db_id IN (%s) + ORDER BY checked_at DESC, id DESC LIMIT ?`, strings.Join(placeholders, ",")), args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64][]map[string]any{} + for rows.Next() { + var sourceDBID int64 + var status, checkedAt string + var latency int + if err := rows.Scan(&sourceDBID, &status, &latency, &checkedAt); err != nil { + return nil, err + } + if len(out[sourceDBID]) >= limit { + continue + } + out[sourceDBID] = append(out[sourceDBID], map[string]any{ + "status": status, + "latencyMs": latency, + "latency_ms": latency, + "checkedAt": checkedAt, + "checked_at": checkedAt, + }) + } + if err := rows.Err(); err != nil { + return nil, err + } + for id, items := range out { + for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 { + items[left], items[right] = items[right], items[left] + } + out[id] = items + } + return out, nil +} + func (s *Store) RecordSourceCall(call SourceCall) error { if call.CreatedAt == "" { call.CreatedAt = Now() diff --git a/server/unified-management/internal/sources/sources.go b/server/unified-management/internal/sources/sources.go index 804ccf1..fdf4680 100644 --- a/server/unified-management/internal/sources/sources.go +++ b/server/unified-management/internal/sources/sources.go @@ -134,7 +134,7 @@ func (s *Service) Stop() { } func (s *Service) loop() { - ticker := time.NewTicker(time.Duration(s.cfg.SourceCheckSeconds) * time.Second) + ticker := time.NewTicker(20 * time.Second) defer ticker.Stop() s.CheckDue(context.Background()) for { @@ -269,6 +269,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) { if err != nil { return nil, err } + histories, _ := s.store.SourceHealthHistory(sourceIDs(items), 16) categories := map[string]map[string]any{} for _, item := range items { cat, ok := categories[item.CategoryID] @@ -319,6 +320,7 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) { "lastError": item.LastError, "consecutiveFailure": item.ConsecutiveFailure, "meta": parseHealthMeta(item.LastError), + "history": histories[item.ID], }, } applyResolvedFields(sub, item.LastError) @@ -340,6 +342,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) { if err != nil { return nil, err } + histories, _ := s.store.SourceHealthHistory(sourceIDs(items), 16) out := []map[string]any{} for _, item := range items { var formats []string @@ -377,6 +380,7 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) { "last_error": item.LastError, "consecutiveFailure": item.ConsecutiveFailure, "meta": parseHealthMeta(item.LastError), + "history": histories[item.ID], }, } applyResolvedFields(endpoint, item.LastError) @@ -385,23 +389,48 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) { return out, nil } +func sourceIDs(items []db.Source) []int64 { + out := make([]int64, 0, len(items)) + for _, item := range items { + if item.ID > 0 { + out = append(out, item.ID) + } + } + return out +} + func (s *Service) CheckDue(ctx context.Context) { items, err := s.store.ListSources(true) if err != nil { return } - now := time.Now() + enabled := make([]db.Source, 0, len(items)) for _, item := range items { if !item.Enabled { continue } - if item.LastCheckedAt != "" { - if last, err := time.Parse(time.RFC3339, item.LastCheckedAt); err == nil && now.Sub(last) < time.Duration(item.CheckIntervalSec)*time.Second { - continue - } - } - _ = s.CheckOne(ctx, item) + enabled = append(enabled, item) } + if len(enabled) == 0 { + return + } + const concurrency = 4 + work := make(chan db.Source) + var wg sync.WaitGroup + for i := 0; i < concurrency; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for item := range work { + _ = s.CheckOne(ctx, item) + } + }() + } + for _, item := range enabled { + work <- item + } + close(work) + wg.Wait() } func (s *Service) QueueCheckAll() CheckJob { diff --git a/server/unified-management/internal/web/admin_system_routes.go b/server/unified-management/internal/web/admin_system_routes.go index 0e27880..e8313c3 100644 --- a/server/unified-management/internal/web/admin_system_routes.go +++ b/server/unified-management/internal/web/admin_system_routes.go @@ -343,21 +343,39 @@ func (r *router) handleBranding(w http.ResponseWriter, req *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "branding": config.SafeBranding(r.effectiveBranding())}) case http.MethodPost: var body struct { - SiteIconURL string `json:"siteIconUrl"` - SiteIconURLSnake string `json:"site_icon_url"` - DeveloperAvatarURL string `json:"developerAvatarUrl"` - DeveloperAvatarAlt string `json:"developer_avatar_url"` - DeveloperName string `json:"developerName"` - DeveloperNameSnake string `json:"developer_name"` - FeedbackEmail string `json:"feedbackEmail"` - FeedbackEmailSnake string `json:"feedback_email"` + SiteName string `json:"siteName"` + SiteNameSnake string `json:"site_name"` + PortalTitle string `json:"portalTitle"` + PortalTitleSnake string `json:"portal_title"` + PortalSubtitle string `json:"portalSubtitle"` + PortalSubtitleSnake string `json:"portal_subtitle"` + AdminTitle string `json:"adminTitle"` + AdminTitleSnake string `json:"admin_title"` + AdminSubtitle string `json:"adminSubtitle"` + AdminSubtitleSnake string `json:"admin_subtitle"` + SiteIconURL string `json:"siteIconUrl"` + SiteIconURLSnake string `json:"site_icon_url"` + LogoURL string `json:"logoUrl"` + LogoURLSnake string `json:"logo_url"` + DeveloperAvatarURL string `json:"developerAvatarUrl"` + DeveloperAvatarAlt string `json:"developer_avatar_url"` + DeveloperName string `json:"developerName"` + DeveloperNameSnake string `json:"developer_name"` + FeedbackEmail string `json:"feedbackEmail"` + FeedbackEmailSnake string `json:"feedback_email"` } if err := json.NewDecoder(req.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err) return } next := config.BrandingConfig{ + SiteName: firstNonEmpty(body.SiteName, body.SiteNameSnake), + PortalTitle: firstNonEmpty(body.PortalTitle, body.PortalTitleSnake), + PortalSubtitle: firstNonEmpty(body.PortalSubtitle, body.PortalSubtitleSnake), + AdminTitle: firstNonEmpty(body.AdminTitle, body.AdminTitleSnake), + AdminSubtitle: firstNonEmpty(body.AdminSubtitle, body.AdminSubtitleSnake), SiteIconURL: firstNonEmpty(body.SiteIconURL, body.SiteIconURLSnake), + LogoURL: firstNonEmpty(body.LogoURL, body.LogoURLSnake), DeveloperAvatarURL: firstNonEmpty(body.DeveloperAvatarURL, body.DeveloperAvatarAlt), DeveloperName: firstNonEmpty(body.DeveloperName, body.DeveloperNameSnake), FeedbackEmail: firstNonEmpty(body.FeedbackEmail, body.FeedbackEmailSnake), diff --git a/server/unified-management/internal/web/router_test.go b/server/unified-management/internal/web/router_test.go index ca7aa8b..16098c2 100644 --- a/server/unified-management/internal/web/router_test.go +++ b/server/unified-management/internal/web/router_test.go @@ -138,6 +138,9 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) { if branding["developerName"] != "YMhut" || branding["feedbackEmail"] != "support@ymhut.cn" { t.Fatalf("unexpected branding defaults: %#v", branding) } + if branding["siteName"] != "YMhut Box" || branding["portalTitle"] != "YMhut Box 统一服务门户" || branding["adminTitle"] != "YMhut 统一管理台" { + t.Fatalf("unexpected extended branding defaults: %#v", branding) + } } if payload["ok"] != true { t.Fatalf("%s missing ok=true: %#v", path, payload) @@ -194,7 +197,7 @@ func TestAdminAuditPaginationAndBranding(t *testing.T) { } for i := 0; i < 40; i++ { - body := strings.NewReader(`{"developerName":"YMhut","feedbackEmail":"support@ymhut.cn"}`) + body := strings.NewReader(`{"siteName":"YMhut Box Pro","portalTitle":"前台服务门户","portalSubtitle":"公开发布与接口状态","adminTitle":"后台统一服务管理","adminSubtitle":"发布、反馈与运维","siteIconUrl":"/assets/favicon.ico","logoUrl":"/assets/favicon.ico","developerName":"YMhut","feedbackEmail":"support@ymhut.cn"}`) req := httptest.NewRequest(http.MethodPost, "/api/admin/system/branding", body) req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session}) req.Header.Set("X-CSRF-Token", csrf) @@ -204,6 +207,17 @@ func TestAdminAuditPaginationAndBranding(t *testing.T) { if res.Code != http.StatusOK { t.Fatalf("branding save %d returned %d: %s", i, res.Code, res.Body.String()) } + if i == 0 { + var saved struct { + Branding map[string]any `json:"branding"` + } + if err := json.Unmarshal(res.Body.Bytes(), &saved); err != nil { + t.Fatal(err) + } + if saved.Branding["siteName"] != "YMhut Box Pro" || saved.Branding["portalTitle"] != "前台服务门户" || saved.Branding["adminTitle"] != "后台统一服务管理" || saved.Branding["logoUrl"] != "/assets/favicon.ico" { + t.Fatalf("extended branding fields not saved: %#v", saved.Branding) + } + } } req := httptest.NewRequest(http.MethodGet, "/api/admin/system/audit?page=1&perPage=35&type=system.branding.saved", nil) diff --git a/server/unified-management/web/admin/package-lock.json b/server/unified-management/web/admin/package-lock.json index 11b80be..1d3d83c 100644 --- a/server/unified-management/web/admin/package-lock.json +++ b/server/unified-management/web/admin/package-lock.json @@ -8,9 +8,12 @@ "name": "ymhut-unified-admin", "version": "0.1.0", "dependencies": { + "@primeuix/themes": "^1.2.3", "@vitejs/plugin-vue": "^5.2.4", "echarts": "^6.1.0", "lucide-vue-next": "^0.468.0", + "primeicons": "^7.0.0", + "primevue": "^4.3.5", "vite": "^6.3.5", "vue": "^3.5.16", "vue-echarts": "^8.0.1", @@ -488,6 +491,74 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, + "node_modules/@primeuix/styled": { + "version": "0.7.4", + "resolved": "https://registry.npmmirror.com/@primeuix/styled/-/styled-0.7.4.tgz", + "integrity": "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ==", + "license": "MIT", + "dependencies": { + "@primeuix/utils": "^0.6.1" + }, + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primeuix/styles": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@primeuix/styles/-/styles-2.0.3.tgz", + "integrity": "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4" + } + }, + "node_modules/@primeuix/themes": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/@primeuix/themes/-/themes-1.2.5.tgz", + "integrity": "sha512-n3YkwJrHQaEESc/D/A/iD815sxp8cKnmzscA6a8Tm8YvMtYU32eCahwLLe6h5rywghVwxASWuG36XBgISYOIjQ==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.3" + } + }, + "node_modules/@primeuix/utils": { + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/@primeuix/utils/-/utils-0.6.4.tgz", + "integrity": "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg==", + "license": "MIT", + "engines": { + "node": ">=12.11.0" + } + }, + "node_modules/@primevue/core": { + "version": "4.5.5", + "resolved": "https://registry.npmmirror.com/@primevue/core/-/core-4.5.5.tgz", + "integrity": "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4", + "@primeuix/utils": "^0.6.2" + }, + "engines": { + "node": ">=12.11.0" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@primevue/icons": { + "version": "4.5.5", + "resolved": "https://registry.npmmirror.com/@primevue/icons/-/icons-4.5.5.tgz", + "integrity": "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA==", + "license": "MIT", + "dependencies": { + "@primeuix/utils": "^0.6.2", + "@primevue/core": "4.5.5" + }, + "engines": { + "node": ">=12.11.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -1165,6 +1236,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/primeicons": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/primeicons/-/primeicons-7.0.0.tgz", + "integrity": "sha512-jK3Et9UzwzTsd6tzl2RmwrVY/b8raJ3QZLzoDACj+oTJ0oX7L9Hy+XnVwgo4QVKlKpnP/Ur13SXV/pVh4LzaDw==", + "license": "MIT" + }, + "node_modules/primevue": { + "version": "4.5.5", + "resolved": "https://registry.npmmirror.com/primevue/-/primevue-4.5.5.tgz", + "integrity": "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A==", + "license": "MIT", + "dependencies": { + "@primeuix/styled": "^0.7.4", + "@primeuix/styles": "^2.0.3", + "@primeuix/utils": "^0.6.2", + "@primevue/core": "4.5.5", + "@primevue/icons": "4.5.5" + }, + "engines": { + "node": ">=12.11.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", diff --git a/server/unified-management/web/admin/package.json b/server/unified-management/web/admin/package.json index 8e9961e..98b7444 100644 --- a/server/unified-management/web/admin/package.json +++ b/server/unified-management/web/admin/package.json @@ -9,9 +9,12 @@ "preview": "vite preview --host 127.0.0.1" }, "dependencies": { + "@primeuix/themes": "^1.2.3", "@vitejs/plugin-vue": "^5.2.4", "echarts": "^6.1.0", "lucide-vue-next": "^0.468.0", + "primeicons": "^7.0.0", + "primevue": "^4.3.5", "vite": "^6.3.5", "vue": "^3.5.16", "vue-echarts": "^8.0.1", diff --git a/server/unified-management/web/admin/src/App.vue b/server/unified-management/web/admin/src/App.vue index a9ab5de..e6f0504 100644 --- a/server/unified-management/web/admin/src/App.vue +++ b/server/unified-management/web/admin/src/App.vue @@ -14,6 +14,11 @@ import { RefreshCw, ShieldCheck, } from "lucide-vue-next"; +import Button from "primevue/button"; +import ConfirmDialog from "primevue/confirmdialog"; +import Tag from "primevue/tag"; +import Toast from "primevue/toast"; +import Toolbar from "primevue/toolbar"; import EndpointsView from "./views/EndpointsView.vue"; import FeedbacksView from "./views/FeedbacksView.vue"; import LegacyJsonView from "./views/LegacyJsonView.vue"; @@ -29,6 +34,7 @@ import { createLegacyStore, type LegacyName } from "./stores/legacy"; import { createReleaseStore } from "./stores/releases"; import { createSourceStore } from "./stores/sources"; import { createSystemStore } from "./stores/system"; +import { applyDocumentBranding, normalizeBranding } from "./branding"; const DashboardView = defineAsyncComponent(() => import("./views/DashboardView.vue")); @@ -127,6 +133,25 @@ const activeMediaCategory = computed(() => { return categories[activeMediaCategoryIndex.value] || null; }); const systemTab = computed(() => normalizeSystemTab(route.query.tab)); +const sourceRows = computed(() => sourceCategories.value.flatMap((cat: any) => (cat.subcategories || []).map((src: any) => ({ + ...src, + categoryName: cat.name || cat.id || src.categoryName || src.categoryId || "未分类", + status: endpointStatus(src), + latencyMs: sourceLatency(src), + checkedAt: sourceCheckedAt(src), + healthError: sourceHealthError(src), +})))); +const sourceAvailability = computed(() => { + const total = sourceRows.value.length; + const healthy = sourceRows.value.filter((item: any) => ["ok", "redirected"].includes(item.status)).length; + return total ? Math.round((healthy / total) * 100) : 0; +}); +const sourceAverageLatency = computed(() => averageLatency(sourceRows.value.map((item: any) => item.latencyMs))); +const sourceMaxLatency = computed(() => { + const values = sourceRows.value.map((item: any) => Number(item.latencyMs)).filter((item: number) => Number.isFinite(item) && item >= 0); + return values.length ? Math.max(...values) : 0; +}); +const sourceLastCheckedAt = computed(() => sourceRows.value.map((item: any) => item.checkedAt).filter(Boolean).sort().pop() || ""); const heartbeatChartRows = computed(() => { const rows = heartbeats.value .slice() @@ -142,6 +167,19 @@ const heartbeatChartRows = computed(() => { return rows; }); const isHeartbeatChartEmpty = computed(() => heartbeats.value.length === 0); +const averageLatencyRows = computed(() => { + const rows = dashboard.value?.averageLatency || dashboard.value?.average_latency || []; + if (Array.isArray(rows) && rows.length) { + return rows.map((item: any) => ({ + label: item.label || timeLabel(item.checkedAt || item.checked_at), + latency: Number(item.averageLatency ?? item.avgLatencyMs ?? item.average_latency ?? item.latencyMs ?? 0), + sampleCount: Number(item.sampleCount ?? item.sample_count ?? 0), + checkedAt: item.checkedAt || item.checked_at || "", + })).filter((item: any) => Number.isFinite(item.latency)); + } + return heartbeatChartRows.value.map((item: any) => ({ ...item, sampleCount: 1 })); +}); +const isAverageLatencyChartEmpty = computed(() => averageLatencyRows.value.length === 0); const heartbeatOption = computed(() => ({ animation: true, @@ -149,8 +187,8 @@ const heartbeatOption = computed(() => ({ grid: { left: 48, right: 22, top: 28, bottom: 40, containLabel: true }, xAxis: { type: "category", - boundaryGap: heartbeatChartRows.value.length <= 1, - data: heartbeatChartRows.value.map((item: any) => item.label), + boundaryGap: averageLatencyRows.value.length <= 1, + data: averageLatencyRows.value.map((item: any) => item.label), axisLine: { lineStyle: { color: "#cbd5e1" } }, axisLabel: { color: "#64748b" }, }, @@ -171,7 +209,7 @@ const heartbeatOption = computed(() => ({ symbolSize: 7, connectNulls: true, areaStyle: { opacity: 0.18 }, - data: heartbeatChartRows.value.map((item: any) => item.latency), + data: averageLatencyRows.value.map((item: any) => item.latency), color: "#2563eb", lineStyle: { width: 3 }, emphasis: { focus: "series" }, @@ -283,6 +321,7 @@ const viewContext = computed(() => ({ toggleAllFeedbackCodes, bulkUpdateFeedbacks, formatBytes, + formatDateTime, formatHealthOutput, healthOption: healthOption.value, healthSnapshot: healthSnapshot.value, @@ -290,6 +329,7 @@ const viewContext = computed(() => ({ heartbeatOption: heartbeatOption.value, heartbeats: heartbeats.value, isHeartbeatChartEmpty: isHeartbeatChartEmpty.value, + isAverageLatencyChartEmpty: isAverageLatencyChartEmpty.value, importNotices, kpis: kpis.value, labelStatus, @@ -343,7 +383,15 @@ const viewContext = computed(() => ({ selectedNotice: selectedNotice.value, sourceCategories: sourceCategories.value, sourceCheckJobs: sourceCheckJobs.value, + sourceRows: sourceRows.value, + sourceAvailability: sourceAvailability.value, + sourceAverageLatency: sourceAverageLatency.value, + sourceMaxLatency: sourceMaxLatency.value, + sourceLastCheckedAt: sourceLastCheckedAt.value, sourceDraft, + sourceLatency, + sourceCheckedAt, + sourceHealthError, statusTone, syncDatabase, systemLogPage, @@ -483,7 +531,12 @@ async function load() { navigate("/admin/login"); return; } - if (currentPath.value === "/admin/dashboard") await loadDashboard(); + if (currentPath.value === "/admin/dashboard") await Promise.all([ + loadDashboard(), + loadSources().catch(() => undefined), + loadEndpoints().catch(() => undefined), + loadSourceCheckJobs().catch(() => undefined), + ]); if (currentPath.value === "/admin/feedbacks") await loadFeedbacks(); if (currentPath.value === "/admin/releases") await loadReleases(); if (currentPath.value === "/admin/sources") await loadSources(); @@ -1183,12 +1236,8 @@ async function loadMigrationStatus() { async function loadBranding() { const data = await api<{ branding: any }>("/api/admin/system/branding"); - Object.assign(branding, { - siteIconUrl: data.branding?.siteIconUrl || branding.siteIconUrl, - developerAvatarUrl: data.branding?.developerAvatarUrl || branding.developerAvatarUrl, - developerName: data.branding?.developerName || "YMhut", - feedbackEmail: data.branding?.feedbackEmail || "support@ymhut.cn", - }); + Object.assign(branding, normalizeBranding(data.branding || branding)); + applyDocumentBranding(branding, "admin"); } async function saveBranding() { @@ -1196,13 +1245,20 @@ async function saveBranding() { const data = await api<{ branding: any }>("/api/admin/system/branding", { method: "POST", body: JSON.stringify({ + siteName: branding.siteName, + portalTitle: branding.portalTitle, + portalSubtitle: branding.portalSubtitle, + adminTitle: branding.adminTitle, + adminSubtitle: branding.adminSubtitle, siteIconUrl: branding.siteIconUrl, + logoUrl: branding.logoUrl, developerAvatarUrl: branding.developerAvatarUrl, developerName: branding.developerName, feedbackEmail: branding.feedbackEmail, }), }); - Object.assign(branding, data.branding || {}); + Object.assign(branding, normalizeBranding(data.branding || branding)); + applyDocumentBranding(branding, "admin"); if (!mailConfig.developerAddress) mailConfig.developerAddress = branding.feedbackEmail; setToast("站点品牌信息已保存"); }); @@ -1367,6 +1423,38 @@ function endpointStatus(item: any) { return item.health?.status || item.lastStatus || "unknown"; } +function sourceLatency(item: any) { + return firstFiniteNumber(item.health?.latencyMs, item.health?.latency_ms, item.lastLatencyMs, item.last_latency_ms, item.latencyMs, item.latency_ms) ?? 0; +} + +function sourceCheckedAt(item: any) { + return item.health?.lastCheckedAt || item.health?.last_checked_at || item.lastCheckedAt || item.last_checked_at || item.checkedAt || item.checked_at || ""; +} + +function sourceHealthError(item: any) { + return item.health?.lastError || item.health?.last_error || item.lastError || item.last_error || item.error || ""; +} + +function averageLatency(values: unknown[]) { + const numeric = values.map((item) => Number(item)).filter((item) => Number.isFinite(item) && item >= 0); + return numeric.length ? Math.round(numeric.reduce((sum, item) => sum + item, 0) / numeric.length) : 0; +} + +function firstFiniteNumber(...values: unknown[]) { + for (const value of values) { + const numeric = Number(value); + if (Number.isFinite(numeric) && numeric >= 0) return numeric; + } + return null; +} + +function formatDateTime(value: string) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("zh-CN", { hour12: false }); +} + function statusTone(status: string) { const value = String(status || "").toLowerCase(); if (["ok", "online", "new", "sqlite", "mysql", "sent", "ready", "completed"].includes(value)) return "good"; @@ -1540,8 +1628,10 @@ onMounted(() => { localStorage.removeItem("ymhut.csrf"); void load(); refreshTimer = window.setInterval(() => { - if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void loadDashboard(); - }, 15000); + if (!autoRefreshPaused.value && currentPath.value === "/admin/dashboard" && csrf.value) void Promise.all([loadDashboard(), loadSourceCheckJobs().catch(() => undefined)]); + if (!autoRefreshPaused.value && currentPath.value === "/admin/sources" && csrf.value) void Promise.all([loadSources(), loadSourceCheckJobs().catch(() => undefined)]); + if (!autoRefreshPaused.value && currentPath.value === "/admin/endpoints" && csrf.value) void loadEndpoints(); + }, 20000); systemRefreshTimer = window.setInterval(() => { if (!autoRefreshPaused.value && currentPath.value === "/admin/system" && csrf.value) void loadSystem({ preserveForms: true }); }, 60000); @@ -1580,6 +1670,9 @@ function connectAdminEvents() { diff --git a/server/unified-management/web/admin/src/views/EndpointsView.vue b/server/unified-management/web/admin/src/views/EndpointsView.vue index 6a89a3a..1124f1a 100644 --- a/server/unified-management/web/admin/src/views/EndpointsView.vue +++ b/server/unified-management/web/admin/src/views/EndpointsView.vue @@ -13,28 +13,38 @@ defineProps<{ ctx: any }>(); {{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康 - - - - - - - - - - - - - - -
ID分类模式健康缓存URL操作
{{ item.id || item.sourceId }}{{ item.category || item.categoryId }}{{ item.proxyMode }} - {{ ctx.labelStatus(ctx.endpointStatus(item)) }} - 重定向接口 - {{ item.cacheSeconds || 0 }}s{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }} -
- - -
-
暂无客户端接口。
+ +
+
平均延迟{{ ctx.averageLatency(ctx.endpoints.map((item: any) => ctx.sourceLatency(item))) }}ms
+
总接口{{ ctx.endpoints.length }}
+
健康接口{{ ctx.healthyEndpointCount }}
+
+ +
+ + + + + + + + + + + + + + + +
ID分类模式健康实时延迟最近检测URL操作
{{ item.id || item.sourceId }}{{ item.category || item.categoryId }}{{ item.proxyMode }} + {{ ctx.labelStatus(ctx.endpointStatus(item)) }} + 重定向接口 + {{ ctx.sourceLatency(item) }}ms{{ ctx.formatDateTime(ctx.sourceCheckedAt(item)) }}{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }} +
+ + +
+
暂无客户端接口。
+
diff --git a/server/unified-management/web/admin/src/views/SourcesView.vue b/server/unified-management/web/admin/src/views/SourcesView.vue index 5c847f4..b848b85 100644 --- a/server/unified-management/web/admin/src/views/SourcesView.vue +++ b/server/unified-management/web/admin/src/views/SourcesView.vue @@ -5,25 +5,43 @@ defineProps<{ ctx: any }>();