更新UI
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<SystemTab>(() => 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() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="toast" :class="['toast', toast.type]">{{ toast.message }}</div>
|
||||
</Teleport>
|
||||
@@ -1587,9 +1680,9 @@ function connectAdminEvents() {
|
||||
<main v-if="currentPath === '/admin/login'" class="login-shell">
|
||||
<section class="login-panel">
|
||||
<div>
|
||||
<p class="eyebrow">YMhut Unified Management</p>
|
||||
<p class="eyebrow">{{ branding.siteName }}</p>
|
||||
<h1>后台登录</h1>
|
||||
<p class="muted">验证码和密码都由服务端校验,登录后写操作继续要求 CSRF Token。</p>
|
||||
<p class="muted">{{ branding.adminSubtitle }}。验证码和密码都由服务端校验,登录后写操作继续要求 CSRF Token。</p>
|
||||
</div>
|
||||
<p v-if="authBootstrap?.isDefaultPassword" class="alert-line">
|
||||
当前使用默认账号:{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }}
|
||||
@@ -1607,7 +1700,7 @@ function connectAdminEvents() {
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn primary full" type="submit">登录</button>
|
||||
<Button class="full" type="submit" label="登录" />
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
@@ -1616,10 +1709,10 @@ function connectAdminEvents() {
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">
|
||||
<img v-if="branding.siteIconUrl" :src="branding.siteIconUrl" alt="YMhut" />
|
||||
<img v-if="branding.logoUrl || branding.siteIconUrl" :src="branding.logoUrl || branding.siteIconUrl" :alt="branding.siteName" />
|
||||
<ShieldCheck v-else :size="22" />
|
||||
</span>
|
||||
<div><strong>{{ branding.developerName || "YMhut" }}</strong><small>统一管理台</small></div>
|
||||
<div><strong>{{ branding.adminTitle || "统一管理台" }}</strong><small>{{ branding.siteName }}</small></div>
|
||||
</div>
|
||||
<nav class="nav-groups">
|
||||
<section v-for="group in navGroups" :key="group.label" class="nav-group">
|
||||
@@ -1639,17 +1732,21 @@ function connectAdminEvents() {
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">update.ymhut.cn</p>
|
||||
<h1>{{ pageMeta.label }}</h1>
|
||||
<p class="muted">{{ pageMeta.description }}</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span v-if="loading" class="badge warn">加载中</span>
|
||||
<button class="btn ghost" @click="load"><RefreshCw :size="16" />刷新</button>
|
||||
</div>
|
||||
</header>
|
||||
<Toolbar class="topbar">
|
||||
<template #start>
|
||||
<div>
|
||||
<p class="eyebrow">{{ branding.adminSubtitle }}</p>
|
||||
<h1>{{ pageMeta.label }}</h1>
|
||||
<p class="muted">{{ pageMeta.description }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template #end>
|
||||
<div class="top-actions">
|
||||
<Tag v-if="loading" severity="warn" value="加载中" />
|
||||
<Button severity="secondary" outlined rounded @click="load"><RefreshCw :size="16" />刷新</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
<DashboardView v-if="currentPath === '/admin/dashboard'" :ctx="viewContext" />
|
||||
<FeedbacksView v-else-if="currentPath === '/admin/feedbacks'" :ctx="viewContext" />
|
||||
<ReleasesView v-else-if="currentPath === '/admin/releases'" :ctx="viewContext" />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export type Branding = {
|
||||
siteName: string;
|
||||
portalTitle: string;
|
||||
portalSubtitle: string;
|
||||
adminTitle: string;
|
||||
adminSubtitle: string;
|
||||
siteIconUrl: string;
|
||||
logoUrl: string;
|
||||
developerAvatarUrl: string;
|
||||
developerName: string;
|
||||
feedbackEmail: string;
|
||||
};
|
||||
|
||||
export const defaultBranding: Branding = {
|
||||
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",
|
||||
};
|
||||
|
||||
function valueOf(source: any, key: keyof Branding) {
|
||||
const value = source?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeBranding(source: any): Branding {
|
||||
const siteName = valueOf(source, "siteName") || defaultBranding.siteName;
|
||||
const siteIconUrl = valueOf(source, "siteIconUrl") || defaultBranding.siteIconUrl;
|
||||
return {
|
||||
siteName,
|
||||
portalTitle: valueOf(source, "portalTitle") || `${siteName} 统一服务门户`,
|
||||
portalSubtitle: valueOf(source, "portalSubtitle") || defaultBranding.portalSubtitle,
|
||||
adminTitle: valueOf(source, "adminTitle") || defaultBranding.adminTitle,
|
||||
adminSubtitle: valueOf(source, "adminSubtitle") || defaultBranding.adminSubtitle,
|
||||
siteIconUrl,
|
||||
logoUrl: valueOf(source, "logoUrl") || siteIconUrl,
|
||||
developerAvatarUrl: valueOf(source, "developerAvatarUrl") || defaultBranding.developerAvatarUrl,
|
||||
developerName: valueOf(source, "developerName") || defaultBranding.developerName,
|
||||
feedbackEmail: valueOf(source, "feedbackEmail") || defaultBranding.feedbackEmail,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDocumentBranding(branding: Branding, surface: "portal" | "admin" = "admin") {
|
||||
if (typeof document === "undefined") return;
|
||||
document.title = surface === "admin" ? branding.adminTitle : branding.portalTitle;
|
||||
let icon = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
|
||||
if (!icon) {
|
||||
icon = document.createElement("link");
|
||||
icon.rel = "icon";
|
||||
document.head.appendChild(icon);
|
||||
}
|
||||
icon.href = branding.siteIconUrl;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { createApp } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import PrimeVue from "primevue/config";
|
||||
import Aura from "@primeuix/themes/aura";
|
||||
import ToastService from "primevue/toastservice";
|
||||
import ConfirmationService from "primevue/confirmationservice";
|
||||
import "primeicons/primeicons.css";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
@@ -30,4 +35,25 @@ const router = createRouter({
|
||||
],
|
||||
});
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(PrimeVue, {
|
||||
ripple: true,
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: ".admin-dark",
|
||||
},
|
||||
},
|
||||
locale: {
|
||||
accept: "确定",
|
||||
reject: "取消",
|
||||
clear: "清除",
|
||||
apply: "应用",
|
||||
emptyMessage: "暂无数据",
|
||||
emptyFilterMessage: "没有匹配结果",
|
||||
},
|
||||
})
|
||||
.use(ToastService)
|
||||
.use(ConfirmationService)
|
||||
.mount("#app");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { reactive, ref } from "vue";
|
||||
import { defaultBranding } from "../branding";
|
||||
|
||||
export function createSystemStore() {
|
||||
const database = ref<any>(null);
|
||||
@@ -29,10 +30,7 @@ export function createSystemStore() {
|
||||
});
|
||||
const migrationStatus = ref<any>(null);
|
||||
const branding = reactive({
|
||||
siteIconUrl: "/assets/favicon.ico",
|
||||
developerAvatarUrl: "/assets/developer-avatar.png",
|
||||
developerName: "YMhut",
|
||||
feedbackEmail: "support@ymhut.cn",
|
||||
...defaultBranding,
|
||||
});
|
||||
const databaseForm = reactive({
|
||||
provider: "sqlite",
|
||||
|
||||
@@ -173,8 +173,20 @@ input:focus, textarea:focus, select:focus {
|
||||
overflow: hidden auto;
|
||||
}
|
||||
.brand { display: flex; gap: 12px; align-items: center; min-width: 0; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.brand-mark { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; background: #111827; color: #fff; }
|
||||
.brand-mark img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; display: block; }
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(248, 250, 252, 0.96);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--primary-dark);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
}
|
||||
.brand-mark img { width: 100%; height: 100%; object-fit: contain; border-radius: 8px; display: block; }
|
||||
.brand strong { display: block; }
|
||||
.brand small { display: block; color: var(--muted); margin-top: 2px; }
|
||||
.brand > div { min-width: 0; overflow: hidden; }
|
||||
@@ -268,6 +280,33 @@ input:focus, textarea:focus, select:focus {
|
||||
pointer-events: none;
|
||||
}
|
||||
.chart-empty strong { color: var(--ink); }
|
||||
.health-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.health-kpis.compact { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.health-kpis article {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-soft);
|
||||
padding: 12px;
|
||||
}
|
||||
.health-kpis svg { color: var(--primary); }
|
||||
.health-kpis span, .block {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.health-kpis strong {
|
||||
color: var(--ink);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.latency-value { color: var(--good); white-space: nowrap; }
|
||||
.latency-value.slow { color: var(--warn); }
|
||||
.block { display: block; margin-top: 4px; }
|
||||
.split { display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 14px; align-items: start; }
|
||||
.split.wide-split { grid-template-columns: minmax(380px, 0.95fr) minmax(0, 1.05fr); }
|
||||
.legacy-media-editor { grid-template-columns: minmax(340px, 0.95fr) minmax(0, 1.05fr); }
|
||||
@@ -511,8 +550,10 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
@@ -626,7 +667,7 @@ summary { cursor: pointer; font-weight: 900; margin-bottom: 10px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.chart-grid, .split, .split.wide-split, .sync-summary { grid-template-columns: 1fr; }
|
||||
.chart-grid, .split, .split.wide-split, .sync-summary, .health-kpis, .health-kpis.compact { grid-template-columns: 1fr; }
|
||||
.detail-panel { position: static; max-height: none; }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { BarChart, GaugeChart, LineChart, PieChart } from "echarts/charts";
|
||||
import { GridComponent, LegendComponent, TooltipComponent } from "echarts/components";
|
||||
import { Activity, PauseCircle, PlayCircle } from "lucide-vue-next";
|
||||
import { Activity, Gauge, PauseCircle, PlayCircle, TimerReset } from "lucide-vue-next";
|
||||
|
||||
defineProps<{ ctx: any }>();
|
||||
|
||||
@@ -15,8 +15,8 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<section class="page-stack">
|
||||
<div class="metric-grid">
|
||||
<article class="metric"><span>反馈总数</span><strong>{{ ctx.kpis.feedbackTotal || 0 }}</strong><small>今日新增 {{ ctx.kpis.feedbackToday || 0 }}</small></article>
|
||||
<article class="metric"><span>可见接口</span><strong>{{ ctx.kpis.sourceVisible || 0 }}</strong><small>接口总数 {{ ctx.kpis.sourceTotal || 0 }}</small></article>
|
||||
<article class="metric"><span>版本日志</span><strong>{{ ctx.kpis.releaseNotices || 0 }}</strong><small>{{ ctx.latestNotice && ctx.latestNotice.version ? ctx.latestNotice.version : "暂无最新版本" }}</small></article>
|
||||
<article class="metric"><span>接口可用率</span><strong>{{ ctx.sourceAvailability || 0 }}%</strong><small>{{ ctx.sourceRows.length }} 个接口 · {{ ctx.sourceAverageLatency || 0 }}ms 平均延迟</small></article>
|
||||
<article class="metric"><span>版本日志</span><strong>{{ ctx.kpis.releaseNotices || 0 }}</strong><small>{{ ctx.latestNotice?.version || "暂无最新版本" }}</small></article>
|
||||
<article class="metric"><span>邮件失败</span><strong>{{ ctx.kpis.mailFailed || 0 }}</strong><small>旧反馈兼容记录</small></article>
|
||||
</div>
|
||||
|
||||
@@ -26,35 +26,43 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<component :is="ctx.autoRefreshPaused ? PlayCircle : PauseCircle" :size="16" />
|
||||
{{ ctx.autoRefreshPaused ? "恢复自动刷新" : "暂停自动刷新" }}
|
||||
</button>
|
||||
<span class="muted">每 15 秒自动刷新。</span>
|
||||
<span v-if="ctx.lastRefreshedAt" class="muted">上次刷新:{{ ctx.lastRefreshedAt }}</span>
|
||||
<span class="muted">每 20 秒自动刷新接口状态。</span>
|
||||
<span v-if="ctx.sourceLastCheckedAt" class="muted">最近检测:{{ ctx.formatDateTime(ctx.sourceLastCheckedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="health-kpis">
|
||||
<article><Gauge :size="18" /><span>平均延迟</span><strong>{{ ctx.sourceAverageLatency || 0 }}ms</strong></article>
|
||||
<article><TimerReset :size="18" /><span>最大延迟</span><strong>{{ ctx.sourceMaxLatency || 0 }}ms</strong></article>
|
||||
<article><Activity :size="18" /><span>健康接口</span><strong>{{ ctx.healthyEndpointCount || 0 }}/{{ ctx.visibleEndpointCount || 0 }}</strong></article>
|
||||
</div>
|
||||
|
||||
<section v-if="ctx.sourceCheckJobs.length" class="panel">
|
||||
<div class="section-head"><h2>服务端检测任务</h2><span class="badge">{{ ctx.sourceCheckJobs[0].status }}</span></div>
|
||||
<table>
|
||||
<thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="job in ctx.sourceCheckJobs.slice(0, 5)" :key="job.id">
|
||||
<td class="mono">{{ job.id }}</td>
|
||||
<td>{{ job.checked || 0 }} / {{ job.total || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.ok) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.redirected) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.degraded) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.error) || 0 }}</td>
|
||||
<td>{{ job.startedAt || "-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>任务</th><th>进度</th><th>正常</th><th>重定向</th><th>降级</th><th>错误</th><th>开始时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="job in ctx.sourceCheckJobs.slice(0, 5)" :key="job.id">
|
||||
<td class="mono">{{ job.id }}</td>
|
||||
<td>{{ job.checked || 0 }} / {{ job.total || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.ok) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.redirected) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.degraded) || 0 }}</td>
|
||||
<td>{{ (job.stats && job.stats.error) || 0 }}</td>
|
||||
<td>{{ ctx.formatDateTime(job.startedAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="chart-grid">
|
||||
<section class="panel chart-panel chart-panel-relative">
|
||||
<h2>服务端接口延迟</h2>
|
||||
<h2>所有接口平均延迟</h2>
|
||||
<VChart class="chart" :option="ctx.heartbeatOption" autoresize />
|
||||
<div v-if="ctx.isHeartbeatChartEmpty" class="chart-empty">
|
||||
<strong>暂无服务端检测记录</strong>
|
||||
<span>点击立即服务端检测后会生成延迟曲线。</span>
|
||||
<div v-if="ctx.isAverageLatencyChartEmpty" class="chart-empty">
|
||||
<strong>暂无平均延迟记录</strong>
|
||||
<span>服务端 5 秒检测产生记录后会自动绘制趋势。</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel chart-panel"><h2>接口健康分布</h2><VChart class="chart" :option="ctx.healthOption" autoresize /></section>
|
||||
@@ -63,44 +71,49 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>最近服务端检测</h2><span class="badge">{{ ctx.heartbeats.length }} 条</span></div>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>输出</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.heartbeats.slice(0, 10)" :key="item.id">
|
||||
<td>{{ item.name || item.sourceId }}</td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ ctx.formatHealthOutput(item) }}</td>
|
||||
<td>{{ item.checkedAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.heartbeats.length === 0"><td colspan="5">暂无检测记录,点击立即服务端检测后会刷新。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="section-head"><h2>每接口实时延迟</h2><span class="badge">{{ ctx.sourceRows.length }} 个接口</span></div>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>分类</th><th>状态</th><th>延迟</th><th>最近检测</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.sourceRows.slice(0, 14)" :key="item.id || item.sourceId">
|
||||
<td>{{ item.name || item.sourceId }}</td>
|
||||
<td>{{ item.categoryName || item.categoryId || "-" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td><strong :class="['latency-value', Number(item.latencyMs) >= 1500 ? 'slow' : '']">{{ item.latencyMs || 0 }}ms</strong></td>
|
||||
<td>{{ ctx.formatDateTime(item.checkedAt) }}</td>
|
||||
<td class="hash">{{ item.healthError || "检测正常" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.sourceRows.length === 0"><td colspan="6">暂无接口检测数据。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head"><h2>客户端调用上报</h2><span class="badge">{{ ctx.clientCalls.length }} 条</span></div>
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>客户端</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
||||
<td>{{ item.sourceId }}</td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ item.client || "-" }}</td>
|
||||
<td>{{ item.createdAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.clientCalls.length === 0"><td colspan="5">暂无客户端调用上报。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>接口</th><th>状态</th><th>延迟</th><th>客户端</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.clientCalls.slice(0, 8)" :key="item.id">
|
||||
<td>{{ item.sourceId }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.latencyMs || 0 }}ms</td>
|
||||
<td class="hash">{{ item.client || "-" }}</td>
|
||||
<td>{{ ctx.formatDateTime(item.createdAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.clientCalls.length === 0"><td colspan="5">暂无客户端调用上报。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-head">
|
||||
<h2>系统日志</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select v-model="ctx.systemLogPage.category" style="width:100px" @change="ctx.loadSystemLogs">
|
||||
<div class="button-row">
|
||||
<select v-model="ctx.systemLogPage.category" style="width:120px" @change="ctx.loadSystemLogs">
|
||||
<option value="">全部分类</option>
|
||||
<option value="feedback">反馈</option>
|
||||
<option value="release">发布</option>
|
||||
@@ -110,22 +123,24 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, GaugeChart, GridComponent, T
|
||||
<option value="database">数据库</option>
|
||||
<option value="risk">风险</option>
|
||||
</select>
|
||||
<button class="btn ghost" style="padding:3px 10px;font-size:12px" @click="ctx.loadSystemLogs">刷新</button>
|
||||
<button class="btn ghost compact" @click="ctx.loadSystemLogs">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>分类</th><th>类型</th><th>状态</th><th>消息</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in (ctx.systemLogPage.items || []).slice(0, 20)" :key="item.id">
|
||||
<td><span class="badge neutral">{{ item.category || "-" }}</span></td>
|
||||
<td class="mono">{{ item.type || "-" }}</td>
|
||||
<td><span :class='["badge", ctx.statusTone(item.status)]'>{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.message || "-" }}</td>
|
||||
<td>{{ item.createdAt || "-" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.systemLogPage.items || []).length"><td colspan="5">暂无系统日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>分类</th><th>类型</th><th>状态</th><th>消息</th><th>时间</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in (ctx.systemLogPage.items || []).slice(0, 20)" :key="item.id">
|
||||
<td><span class="badge neutral">{{ item.category || "-" }}</span></td>
|
||||
<td class="mono">{{ item.type || "-" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(item.status)]">{{ ctx.labelStatus(item.status) }}</span></td>
|
||||
<td>{{ item.message || "-" }}</td>
|
||||
<td>{{ ctx.formatDateTime(item.createdAt) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!(ctx.systemLogPage.items || []).length"><td colspan="5">暂无系统日志。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -13,28 +13,38 @@ defineProps<{ ctx: any }>();
|
||||
</div>
|
||||
<span class="badge">{{ ctx.visibleEndpointCount }} 可见 / {{ ctx.healthyEndpointCount }} 健康</span>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>缓存</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||
<td>{{ item.category || item.categoryId }}</td>
|
||||
<td>{{ item.proxyMode }}</td>
|
||||
<td>
|
||||
<span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.labelStatus(ctx.endpointStatus(item)) }}</span>
|
||||
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||
</td>
|
||||
<td>{{ item.cacheSeconds || 0 }}s</td>
|
||||
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="7">暂无客户端接口。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="health-kpis compact">
|
||||
<article><span>平均延迟</span><strong>{{ ctx.averageLatency(ctx.endpoints.map((item: any) => ctx.sourceLatency(item))) }}ms</strong></article>
|
||||
<article><span>总接口</span><strong>{{ ctx.endpoints.length }}</strong></article>
|
||||
<article><span>健康接口</span><strong>{{ ctx.healthyEndpointCount }}</strong></article>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>分类</th><th>模式</th><th>健康</th><th>实时延迟</th><th>最近检测</th><th>URL</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in ctx.endpoints" :key="item.id || item.sourceId">
|
||||
<td class="mono">{{ item.id || item.sourceId }}</td>
|
||||
<td>{{ item.category || item.categoryId }}</td>
|
||||
<td>{{ item.proxyMode }}</td>
|
||||
<td>
|
||||
<span :class="['badge', ctx.statusTone(ctx.endpointStatus(item))]">{{ ctx.labelStatus(ctx.endpointStatus(item)) }}</span>
|
||||
<span v-if="ctx.endpointStatus(item) === 'redirected' || item.health?.meta?.redirected" class="badge warn">重定向接口</span>
|
||||
</td>
|
||||
<td><strong :class="['latency-value', ctx.sourceLatency(item) >= 1500 ? 'slow' : '']">{{ ctx.sourceLatency(item) }}ms</strong></td>
|
||||
<td>{{ ctx.formatDateTime(ctx.sourceCheckedAt(item)) }}</td>
|
||||
<td class="hash">{{ item.resolvedUrl || item.urlTemplate || item.apiUrl }}</td>
|
||||
<td>
|
||||
<div class="button-row">
|
||||
<button class="btn ghost compact" @click="ctx.copyEndpointToSource(item)"><Pencil :size="14" />编辑</button>
|
||||
<button class="btn ghost compact danger" @click="ctx.deleteEndpoint(item)"><Trash2 :size="14" />删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.endpoints.length === 0"><td colspan="8">暂无客户端接口。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -5,25 +5,43 @@ defineProps<{ ctx: any }>();
|
||||
<template>
|
||||
<section class="split">
|
||||
<section class="panel page-stack">
|
||||
<div class="section-head"><h2>媒体/数据源</h2><button class="btn primary" @click="ctx.checkSources">批量检测</button></div>
|
||||
<div v-for="cat in ctx.sourceCategories" :key="cat.id || cat.name" class="source-group">
|
||||
<h3>{{ cat.name || cat.id }} <span class="badge">{{ cat.subcategories?.length || 0 }}</span></h3>
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h2>媒体/数据源</h2>
|
||||
<p class="muted">服务端每 20 秒独立检测所有启用接口,表格展示最近一次状态与延迟。</p>
|
||||
</div>
|
||||
<button class="btn primary" @click="ctx.checkSources">批量检测</button>
|
||||
</div>
|
||||
|
||||
<div class="health-kpis">
|
||||
<article><span>可用率</span><strong>{{ ctx.sourceAvailability || 0 }}%</strong></article>
|
||||
<article><span>平均延迟</span><strong>{{ ctx.sourceAverageLatency || 0 }}ms</strong></article>
|
||||
<article><span>最大延迟</span><strong>{{ ctx.sourceMaxLatency || 0 }}ms</strong></article>
|
||||
<article><span>最近检测</span><strong>{{ ctx.formatDateTime(ctx.sourceLastCheckedAt) }}</strong></article>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>名称</th><th>描述</th><th>模式</th><th>状态</th><th>延迟</th><th>URL</th></tr></thead>
|
||||
<thead><tr><th>名称</th><th>分类</th><th>模式</th><th>状态</th><th>延迟</th><th>最近检测</th><th>URL</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="src in cat.subcategories || []" :key="src.id || src.sourceId">
|
||||
<td>{{ src.name }}</td>
|
||||
<td class="hash">{{ src.description || "-" }}</td>
|
||||
<tr v-for="src in ctx.sourceRows" :key="src.id || src.sourceId">
|
||||
<td>
|
||||
<strong>{{ src.name }}</strong>
|
||||
<small class="muted block">{{ src.description || "-" }}</small>
|
||||
</td>
|
||||
<td>{{ src.categoryName || src.categoryId || "-" }}</td>
|
||||
<td>{{ src.proxyMode || src.proxy_mode || "client_direct" }}</td>
|
||||
<td><span :class="['badge', ctx.statusTone(src.health?.status || src.lastStatus)]">{{ src.health?.status || src.lastStatus || "unknown" }}</span></td>
|
||||
<td>{{ src.health?.latency_ms ?? src.lastLatencyMs ?? 0 }}ms</td>
|
||||
<td><span :class="['badge', ctx.statusTone(src.status)]">{{ ctx.labelStatus(src.status) }}</span></td>
|
||||
<td><strong :class="['latency-value', Number(src.latencyMs) >= 1500 ? 'slow' : '']">{{ src.latencyMs || 0 }}ms</strong></td>
|
||||
<td>{{ ctx.formatDateTime(src.checkedAt) }}</td>
|
||||
<td class="hash">{{ src.api_url || src.urlTemplate || src.apiUrl }}</td>
|
||||
</tr>
|
||||
<tr v-if="ctx.sourceRows.length === 0"><td colspan="7">暂无接口源,可从旧 media-types.json 导入或手动添加。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="ctx.sourceCategories.length === 0" class="empty-state">暂无接口源,可从旧 media-types.json 导入或手动添加。</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel editor-panel">
|
||||
<h2>添加/覆盖接口</h2>
|
||||
<label>ID<input v-model="ctx.sourceDraft.sourceId" /></label>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { Activity, AlertTriangle, ArrowDownUp, Clock3, Database, HardDrive, KeyRound, ListChecks, Mail, RefreshCw, Save, ShieldCheck, UserRound } from "lucide-vue-next";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import Image from "primevue/image";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Message from "primevue/message";
|
||||
import Textarea from "primevue/textarea";
|
||||
|
||||
const props = defineProps<{ ctx: any }>();
|
||||
const syncOutputRef = ref<HTMLElement | null>(null);
|
||||
@@ -188,22 +194,48 @@ tabs.splice(tabs.length - 1, 0, { id: "logs", label: "日志中心", icon: ListC
|
||||
<button class="btn primary" @click="ctx.changePassword"><KeyRound :size="16" />保存密码</button>
|
||||
</section>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
<h2>站点品牌</h2>
|
||||
<span class="badge neutral">{{ ctx.branding.developerName || "YMhut" }}</span>
|
||||
</div>
|
||||
<div class="brand-preview">
|
||||
<img :src="ctx.branding.siteIconUrl" alt="站点图标" />
|
||||
<img :src="ctx.branding.developerAvatarUrl" alt="开发者头像" />
|
||||
<strong>{{ ctx.branding.developerName }}</strong>
|
||||
</div>
|
||||
<label>站点图标 URL<input v-model="ctx.branding.siteIconUrl" /></label>
|
||||
<label>开发者头像 URL<input v-model="ctx.branding.developerAvatarUrl" /></label>
|
||||
<label>开发者名称<input v-model="ctx.branding.developerName" /></label>
|
||||
<label>反馈邮箱<input v-model="ctx.branding.feedbackEmail" /></label>
|
||||
<button class="btn primary" @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</button>
|
||||
</section>
|
||||
<Card class="panel editor-panel brand-settings-card">
|
||||
<template #title>站点品牌</template>
|
||||
<template #subtitle>统一控制前台门户、后台标题、图标与开发者信息</template>
|
||||
<template #content>
|
||||
<div class="brand-preview prime-brand-preview">
|
||||
<Image :src="ctx.branding.logoUrl || ctx.branding.siteIconUrl" alt="应用 Logo" width="52" preview />
|
||||
<Image :src="ctx.branding.developerAvatarUrl" alt="开发者头像" width="52" preview />
|
||||
<div>
|
||||
<strong>{{ ctx.branding.siteName || "YMhut Box" }}</strong>
|
||||
<span>{{ ctx.branding.adminTitle || "YMhut 统一管理台" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Message severity="info" :closable="false">保存后前台门户、后台管理台、浏览器标题和 favicon 会使用这里的配置。</Message>
|
||||
<div class="form-grid brand-form-grid">
|
||||
<label>站点名称<InputText v-model="ctx.branding.siteName" /></label>
|
||||
<label>开发者名称<InputText v-model="ctx.branding.developerName" /></label>
|
||||
<label class="wide">前台标题<InputText v-model="ctx.branding.portalTitle" /></label>
|
||||
<label class="wide">前台副标题<Textarea v-model="ctx.branding.portalSubtitle" rows="2" autoResize /></label>
|
||||
<label class="wide">后台标题<InputText v-model="ctx.branding.adminTitle" /></label>
|
||||
<label class="wide">后台副标题<Textarea v-model="ctx.branding.adminSubtitle" rows="2" autoResize /></label>
|
||||
<label>浏览器图标 URL<InputText v-model="ctx.branding.siteIconUrl" /></label>
|
||||
<label>应用 Logo URL<InputText v-model="ctx.branding.logoUrl" /></label>
|
||||
<label>开发者头像 URL<InputText v-model="ctx.branding.developerAvatarUrl" /></label>
|
||||
<label>反馈邮箱<InputText v-model="ctx.branding.feedbackEmail" /></label>
|
||||
</div>
|
||||
<div class="brand-preview-grid">
|
||||
<div>
|
||||
<span>前台预览</span>
|
||||
<strong>{{ ctx.branding.portalTitle }}</strong>
|
||||
<small>{{ ctx.branding.portalSubtitle }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>后台预览</span>
|
||||
<strong>{{ ctx.branding.adminTitle }}</strong>
|
||||
<small>{{ ctx.branding.adminSubtitle }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Button @click="ctx.saveBranding"><UserRound :size="16" />保存品牌</Button>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<section class="panel editor-panel">
|
||||
<div class="section-head">
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
"name": "ymhut-unified-portal",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.5",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "^4.6.4"
|
||||
@@ -486,6 +489,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",
|
||||
@@ -1153,6 +1224,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",
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.5",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16",
|
||||
"vue-router": "^4.6.4"
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import { onMounted } from "vue";
|
||||
import { RouterLink, RouterView, useRoute } from "vue-router";
|
||||
import { Activity, ArrowDownToLine, FileJson, Home, MessageSquareText, Network } from "lucide-vue-next";
|
||||
import Button from "primevue/button";
|
||||
import Message from "primevue/message";
|
||||
import ProgressBar from "primevue/progressbar";
|
||||
import Tag from "primevue/tag";
|
||||
import Toolbar from "primevue/toolbar";
|
||||
import { usePortalState } from "./state";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -20,21 +25,38 @@ onMounted(() => state.load());
|
||||
|
||||
<template>
|
||||
<main class="portal-shell">
|
||||
<nav class="topnav">
|
||||
<RouterLink class="brand" to="/">
|
||||
<span><img :src="state.branding.value.siteIconUrl" :alt="state.branding.value.developerName" /></span>
|
||||
<strong>{{ state.branding.value.developerName }}</strong>
|
||||
</RouterLink>
|
||||
<div class="nav-links">
|
||||
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
|
||||
<component :is="item.icon" :size="15" />{{ item.label }}
|
||||
<Toolbar class="topnav">
|
||||
<template #start>
|
||||
<RouterLink class="brand" to="/">
|
||||
<span><img :src="state.logoUrl.value" :alt="state.branding.value.siteName" /></span>
|
||||
<strong>{{ state.branding.value.siteName }}</strong>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
<template #center>
|
||||
<nav class="nav-links" aria-label="门户导航">
|
||||
<RouterLink v-for="item in navItems" :key="item.path" :to="item.path" :class="{ active: route.path === item.path }">
|
||||
<component :is="item.icon" :size="15" />{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
<template #end>
|
||||
<Button size="small" severity="secondary" text rounded @click="state.refresh">
|
||||
<Activity :size="15" />刷新
|
||||
</Button>
|
||||
</template>
|
||||
</Toolbar>
|
||||
|
||||
<p v-if="state.error.value" class="state-banner error">服务状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-else-if="state.loading.value" class="state-banner loading"><Activity :size="16" />正在读取客户端公开状态...</p>
|
||||
<p v-else-if="state.loadedAt.value" class="state-banner ready">公开状态已更新:{{ state.loadedAt.value.slice(0, 19).replace("T", " ") }}</p>
|
||||
<ProgressBar v-if="state.loading.value" mode="indeterminate" class="loading-bar" />
|
||||
<Message v-if="state.error.value" severity="error" :closable="false" class="state-message">
|
||||
公开状态读取失败:{{ state.error.value }}
|
||||
</Message>
|
||||
<Message v-else-if="state.failedRequests.value.length" severity="warn" :closable="false" class="state-message">
|
||||
部分接口暂时不可用:{{ state.failedRequests.value.map((item) => item.label).join("、") }}
|
||||
</Message>
|
||||
<div v-else-if="state.loadedAt.value" class="state-ready">
|
||||
<Tag severity="success" value="公开状态已更新" />
|
||||
<span>{{ state.loadedAt.value.slice(0, 19).replace("T", " ") }}</span>
|
||||
</div>
|
||||
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
export type Branding = {
|
||||
siteName: string;
|
||||
portalTitle: string;
|
||||
portalSubtitle: string;
|
||||
adminTitle: string;
|
||||
adminSubtitle: string;
|
||||
siteIconUrl: string;
|
||||
logoUrl: string;
|
||||
developerAvatarUrl: string;
|
||||
developerName: string;
|
||||
feedbackEmail: string;
|
||||
};
|
||||
|
||||
export const defaultBranding: Branding = {
|
||||
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",
|
||||
};
|
||||
|
||||
function valueOf(source: any, key: keyof Branding) {
|
||||
const value = source?.[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function normalizeBranding(source: any): Branding {
|
||||
const siteName = valueOf(source, "siteName") || defaultBranding.siteName;
|
||||
const siteIconUrl = valueOf(source, "siteIconUrl") || defaultBranding.siteIconUrl;
|
||||
return {
|
||||
siteName,
|
||||
portalTitle: valueOf(source, "portalTitle") || `${siteName} 统一服务门户`,
|
||||
portalSubtitle: valueOf(source, "portalSubtitle") || defaultBranding.portalSubtitle,
|
||||
adminTitle: valueOf(source, "adminTitle") || defaultBranding.adminTitle,
|
||||
adminSubtitle: valueOf(source, "adminSubtitle") || defaultBranding.adminSubtitle,
|
||||
siteIconUrl,
|
||||
logoUrl: valueOf(source, "logoUrl") || siteIconUrl,
|
||||
developerAvatarUrl: valueOf(source, "developerAvatarUrl") || defaultBranding.developerAvatarUrl,
|
||||
developerName: valueOf(source, "developerName") || defaultBranding.developerName,
|
||||
feedbackEmail: valueOf(source, "feedbackEmail") || defaultBranding.feedbackEmail,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDocumentBranding(branding: Branding, surface: "portal" | "admin" = "portal") {
|
||||
if (typeof document === "undefined") return;
|
||||
document.title = surface === "admin" ? branding.adminTitle : branding.portalTitle;
|
||||
let icon = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
|
||||
if (!icon) {
|
||||
icon = document.createElement("link");
|
||||
icon.rel = "icon";
|
||||
document.head.appendChild(icon);
|
||||
}
|
||||
icon.href = branding.siteIconUrl;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createApp } from "vue";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import PrimeVue from "primevue/config";
|
||||
import Aura from "@primeuix/themes/aura";
|
||||
import "primeicons/primeicons.css";
|
||||
import App from "./App.vue";
|
||||
import OverviewPage from "./pages/OverviewPage.vue";
|
||||
import ReleasesPage from "./pages/ReleasesPage.vue";
|
||||
@@ -19,4 +22,36 @@ const router = createRouter({
|
||||
],
|
||||
});
|
||||
|
||||
createApp(App).use(router).mount("#app");
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(PrimeVue, {
|
||||
ripple: true,
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: ".portal-dark",
|
||||
},
|
||||
},
|
||||
locale: {
|
||||
accept: "确定",
|
||||
reject: "取消",
|
||||
startsWith: "开始于",
|
||||
contains: "包含",
|
||||
notContains: "不包含",
|
||||
endsWith: "结束于",
|
||||
equals: "等于",
|
||||
notEquals: "不等于",
|
||||
noFilter: "无筛选",
|
||||
lt: "小于",
|
||||
lte: "小于等于",
|
||||
gt: "大于",
|
||||
gte: "大于等于",
|
||||
dateIs: "日期为",
|
||||
dateIsNot: "日期不为",
|
||||
dateBefore: "早于",
|
||||
dateAfter: "晚于",
|
||||
clear: "清除",
|
||||
apply: "应用",
|
||||
},
|
||||
})
|
||||
.mount("#app");
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import Accordion from "primevue/accordion";
|
||||
import AccordionContent from "primevue/accordioncontent";
|
||||
import AccordionHeader from "primevue/accordionheader";
|
||||
import AccordionPanel from "primevue/accordionpanel";
|
||||
|
||||
const items = [
|
||||
{ title: "旧版更新能力", body: "旧客户端继续按原有方式读取更新信息、工具状态、模块清单和下载包。" },
|
||||
{ title: "旧版媒体源能力", body: "媒体源目录继续保留旧字段结构,客户端无需修改即可读取。" },
|
||||
@@ -14,10 +19,12 @@ const items = [
|
||||
<p>新旧客户端共用 update.ymhut.cn。门户只展示能力说明,具体接口由客户端自动选择。</p>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article v-for="item in items" :key="item.title" class="panel compat-card">
|
||||
<h2>{{ item.title }}</h2>
|
||||
<p>{{ item.body }}</p>
|
||||
</article>
|
||||
</section>
|
||||
<Accordion value="0" class="compat-accordion">
|
||||
<AccordionPanel v-for="(item, index) in items" :key="item.title" :value="String(index)">
|
||||
<AccordionHeader>{{ item.title }}</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<p>{{ item.body }}</p>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</template>
|
||||
|
||||
@@ -1,24 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { MessageSquareText } from "lucide-vue-next";
|
||||
import { MailCheck, MessageSquareText, Search } from "lucide-vue-next";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputGroup from "primevue/inputgroup";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Message from "primevue/message";
|
||||
import Tag from "primevue/tag";
|
||||
|
||||
const feedbackCode = ref("");
|
||||
const statusUrl = computed(() => feedbackCode.value.trim() ? `/?api=status&code=${encodeURIComponent(feedbackCode.value.trim())}` : "/?api=status&code=");
|
||||
const querying = ref(false);
|
||||
const queryError = ref("");
|
||||
const feedbackStatus = ref<any>(null);
|
||||
const normalizedCode = computed(() => feedbackCode.value.trim());
|
||||
|
||||
async function queryFeedback() {
|
||||
queryError.value = "";
|
||||
feedbackStatus.value = null;
|
||||
if (!normalizedCode.value) {
|
||||
queryError.value = "请输入反馈码后再查询。";
|
||||
return;
|
||||
}
|
||||
querying.value = true;
|
||||
try {
|
||||
const res = await fetch(`/?api=status&code=${encodeURIComponent(normalizedCode.value)}`, { headers: { Accept: "application/json" } });
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok || !data) throw new Error(data?.message || `查询失败,HTTP ${res.status}`);
|
||||
if (data.ok === false) throw new Error(data.message || "未查询到该反馈码。");
|
||||
feedbackStatus.value = data.feedback || data.ticket || data;
|
||||
} catch (error) {
|
||||
queryError.value = error instanceof Error ? error.message : String(error || "查询失败");
|
||||
} finally {
|
||||
querying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusSeverity(value: string) {
|
||||
const status = String(value || "").toLowerCase();
|
||||
if (["closed", "sent", "resolved", "done"].includes(status)) return "success";
|
||||
if (["processing", "pending", "new"].includes(status)) return "warn";
|
||||
if (["failed", "error", "rejected"].includes(status)) return "danger";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function fieldValue(...values: unknown[]) {
|
||||
return values.find((value) => value !== undefined && value !== null && String(value).trim() !== "") || "-";
|
||||
}
|
||||
|
||||
function formatDate(value: unknown) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Feedback</p>
|
||||
<h1>反馈查询</h1>
|
||||
<p>已有反馈可通过反馈码查询公开处理状态。</p>
|
||||
<p>输入反馈码即可查看公开处理进度、回复和邮件状态,结果会在当前页面渲染展示。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel feedback-panel">
|
||||
<h2>查询反馈状态</h2>
|
||||
<div class="feedback-box">
|
||||
<input v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
|
||||
<a class="button primary" :href="statusUrl"><MessageSquareText :size="18" />查询状态</a>
|
||||
</div>
|
||||
<p class="muted">状态查询只返回公开进度、公开回复和接收时间,不展示后台内部处理记录。</p>
|
||||
</section>
|
||||
<Card class="panel feedback-panel">
|
||||
<template #title>查询反馈状态</template>
|
||||
<template #content>
|
||||
<InputGroup class="feedback-box" @keyup.enter="queryFeedback">
|
||||
<InputText v-model="feedbackCode" placeholder="输入反馈码,例如 FB-20260626-0001" />
|
||||
<Button rounded :loading="querying" @click="queryFeedback">
|
||||
<Search :size="18" />查询状态
|
||||
</Button>
|
||||
</InputGroup>
|
||||
<Message v-if="queryError" severity="warn" :closable="false">{{ queryError }}</Message>
|
||||
<p class="muted">状态查询只展示公开进度、公开回复和接收时间,不展示后台内部处理记录。</p>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card v-if="feedbackStatus" class="panel feedback-result">
|
||||
<template #title>
|
||||
<span class="feedback-result-title"><MessageSquareText :size="20" />{{ fieldValue(feedbackStatus.code, normalizedCode) }}</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="feedback-status-head">
|
||||
<Tag :value="fieldValue(feedbackStatus.statusLabel, feedbackStatus.status)" :severity="statusSeverity(feedbackStatus.status)" rounded />
|
||||
<Tag :value="'优先级:' + fieldValue(feedbackStatus.priorityLabel, feedbackStatus.priority)" severity="info" rounded />
|
||||
<Tag :value="'分类:' + fieldValue(feedbackStatus.categoryLabel, feedbackStatus.category)" severity="secondary" rounded />
|
||||
</div>
|
||||
<div class="feedback-fields">
|
||||
<article>
|
||||
<span>状态说明</span>
|
||||
<strong>{{ fieldValue(feedbackStatus.statusDetail, feedbackStatus.detail, feedbackStatus.message, "已收到反馈,正在处理中。") }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>公开回复</span>
|
||||
<strong>{{ fieldValue(feedbackStatus.publicReply, feedbackStatus.reply, "暂无公开回复。") }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>接收时间</span>
|
||||
<strong>{{ formatDate(fieldValue(feedbackStatus.receivedAt, feedbackStatus.createdAt, feedbackStatus.created_at)) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>更新时间</span>
|
||||
<strong>{{ formatDate(fieldValue(feedbackStatus.updatedAt, feedbackStatus.updated_at)) }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>邮件状态</span>
|
||||
<strong><MailCheck :size="16" />{{ fieldValue(feedbackStatus.mailStatus, feedbackStatus.emailStatus, feedbackStatus.mail_status, "未公开") }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, ArrowDownToLine, Box, HeartPulse, Network, ShieldCheck } from "lucide-vue-next";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import Tag from "primevue/tag";
|
||||
import { RouterLink } from "vue-router";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
@@ -9,58 +13,72 @@ const state = usePortalState();
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">update.ymhut.cn</p>
|
||||
<h1>统一发布、反馈与接口源状态门户</h1>
|
||||
<p>统一展示 YMhut Box 的发布状态、反馈入口、接口源可用性与版本日志。新版客户端动态读取服务配置,旧客户端兼容能力继续保留。</p>
|
||||
<h1>{{ state.portalTitle.value }}</h1>
|
||||
<p>{{ state.portalSubtitle.value }}。新版客户端动态读取服务配置,旧客户端兼容能力继续保留。</p>
|
||||
<div class="actions">
|
||||
<a v-if="state.downloadUrl.value" class="button primary" :href="state.downloadUrl.value"><ArrowDownToLine :size="18" />下载最新版本</a>
|
||||
<RouterLink v-else class="button primary" to="/releases"><ArrowDownToLine :size="18" />查看发布状态</RouterLink>
|
||||
<RouterLink class="button" to="/sources"><ShieldCheck :size="18" />查看接口状态</RouterLink>
|
||||
<RouterLink class="button" to="/compatibility">兼容说明</RouterLink>
|
||||
<Button v-if="state.downloadUrl.value" as="a" :href="state.downloadUrl.value" severity="contrast" rounded>
|
||||
<ArrowDownToLine :size="18" />下载最新版本
|
||||
</Button>
|
||||
<RouterLink v-else class="p-button p-button-contrast p-button-rounded" to="/releases">
|
||||
<ArrowDownToLine :size="18" />查看发布状态
|
||||
</RouterLink>
|
||||
<RouterLink class="p-button p-button-secondary p-button-rounded p-button-outlined" to="/sources">
|
||||
<ShieldCheck :size="18" />查看接口状态
|
||||
</RouterLink>
|
||||
<RouterLink class="p-button p-button-secondary p-button-rounded p-button-text" to="/compatibility">兼容说明</RouterLink>
|
||||
</div>
|
||||
<div class="hero-tags">
|
||||
<span>Legacy JSON 兼容</span>
|
||||
<span>接口健康检测</span>
|
||||
<span>反馈状态追踪</span>
|
||||
<Tag value="Legacy JSON 兼容" severity="info" />
|
||||
<Tag value="接口健康检测" severity="success" />
|
||||
<Tag value="反馈状态追踪" severity="secondary" />
|
||||
</div>
|
||||
<p v-if="state.error.value && !state.hasPartialData.value" class="empty strong">暂时无法读取公开客户端接口,请稍后刷新。</p>
|
||||
</div>
|
||||
|
||||
<aside class="release-card">
|
||||
<span class="live-dot">服务在线</span>
|
||||
<span>当前版本</span>
|
||||
<strong>{{ state.appVersion.value }}</strong>
|
||||
<p>{{ state.latestNotice.value?.title || state.releases.value?.title || "服务已启动,等待发布数据同步。" }}</p>
|
||||
<div class="release-meta">
|
||||
<span>{{ state.packages.value.length }} 个发布包</span>
|
||||
<span>{{ state.sourceCount.value }} 个接口源</span>
|
||||
</div>
|
||||
</aside>
|
||||
<Card class="release-card">
|
||||
<template #content>
|
||||
<Tag class="live-dot" value="服务在线" severity="success" />
|
||||
<span>当前版本</span>
|
||||
<strong>{{ state.appVersion.value }}</strong>
|
||||
<p>{{ state.latestNotice.value?.title || state.releases.value?.title || "服务已启动,等待发布数据同步。" }}</p>
|
||||
<div class="release-meta">
|
||||
<span>{{ state.packages.value.length }} 个发布包</span>
|
||||
<span>{{ state.sourceCount.value }} 个接口源</span>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid">
|
||||
<article class="metric"><Box :size="20" /><span>服务版本</span><strong>{{ state.serviceVersion.value }}</strong></article>
|
||||
<article class="metric"><Network :size="20" /><span>可见接口源</span><strong>{{ state.sourceCount.value }}</strong></article>
|
||||
<article class="metric"><HeartPulse :size="20" /><span>健康接口</span><strong>{{ state.healthyCount.value }}</strong></article>
|
||||
<article class="metric"><Activity :size="20" /><span>可用率</span><strong>{{ state.availability.value }}%</strong></article>
|
||||
<Card class="metric"><template #content><Box :size="20" /><span>服务版本</span><strong>{{ state.serviceVersion.value }}</strong></template></Card>
|
||||
<Card class="metric"><template #content><Network :size="20" /><span>可见接口源</span><strong>{{ state.sourceCount.value }}</strong></template></Card>
|
||||
<Card class="metric"><template #content><HeartPulse :size="20" /><span>健康接口</span><strong>{{ state.healthyCount.value }}</strong></template></Card>
|
||||
<Card class="metric"><template #content><Activity :size="20" /><span>可用率</span><strong>{{ state.availability.value }}%</strong></template></Card>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel">
|
||||
<div class="section-head"><h2>服务入口</h2><span class="badge good">运行中</span></div>
|
||||
<div class="route-list">
|
||||
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包、版本公告和 update-notice 日志</span></RouterLink>
|
||||
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源、数据源和动态客户端接口状态</span></RouterLink>
|
||||
<RouterLink to="/feedback"><strong>反馈查询</strong><span>按反馈码查看旧客户端反馈处理状态</span></RouterLink>
|
||||
</div>
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="section-head"><h2>最新版本日志</h2><RouterLink to="/releases">查看全部</RouterLink></div>
|
||||
<div v-if="state.latestNotice.value" class="notice-card">
|
||||
<strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
|
||||
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
|
||||
</div>
|
||||
<p v-else-if="state.loading.value" class="empty">正在读取版本日志...</p>
|
||||
<p v-else class="empty">暂无可展示的版本日志。</p>
|
||||
</article>
|
||||
<Card class="panel">
|
||||
<template #title>服务入口</template>
|
||||
<template #subtitle>运行中</template>
|
||||
<template #content>
|
||||
<div class="route-list">
|
||||
<RouterLink to="/releases"><strong>发布版本</strong><span>下载包、版本公告和 update-notice 日志</span></RouterLink>
|
||||
<RouterLink to="/sources"><strong>接口源健康</strong><span>媒体源、数据源和动态客户端接口状态</span></RouterLink>
|
||||
<RouterLink to="/feedback"><strong>反馈查询</strong><span>按反馈码查看旧客户端反馈处理状态</span></RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<Card class="panel">
|
||||
<template #title>最新版本日志</template>
|
||||
<template #subtitle><RouterLink to="/releases">查看全部</RouterLink></template>
|
||||
<template #content>
|
||||
<div v-if="state.latestNotice.value" class="notice-card">
|
||||
<strong>{{ state.latestNotice.value.title || state.latestNotice.value.version }}</strong>
|
||||
<p>{{ state.latestNotice.value.message || state.latestNotice.value.releaseNotes || "暂无详细说明。" }}</p>
|
||||
</div>
|
||||
<p v-else-if="state.loading.value" class="empty">正在读取版本日志...</p>
|
||||
<p v-else class="empty">暂无可展示的版本日志。</p>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,51 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { BookOpenText } from "lucide-vue-next";
|
||||
import { computed } from "vue";
|
||||
import { BookOpenText, Download, PackageCheck, Sparkles } from "lucide-vue-next";
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import Column from "primevue/column";
|
||||
import DataTable from "primevue/datatable";
|
||||
import Skeleton from "primevue/skeleton";
|
||||
import Tag from "primevue/tag";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
const latestPackage = computed(() => state.packages.value[0] || null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Releases</p>
|
||||
<h1>发布版本</h1>
|
||||
<p>展示客户端可见的发布包、下载入口和版本日志。</p>
|
||||
<p>聚合展示最新版本、发布包、平台架构和版本日志,下载入口保持清晰稳定。</p>
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<article class="panel wide">
|
||||
<div class="section-head"><h2>发布包</h2><span class="badge">{{ state.packages.value.length }} 个可用包</span></div>
|
||||
<table>
|
||||
<thead><tr><th>文件</th><th>版本</th><th>平台</th><th>大小</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="pkg in state.packages.value" :key="pkg.fileName || pkg.url">
|
||||
<td>{{ pkg.fileName || pkg.name || "-" }}</td>
|
||||
<td>{{ pkg.version || state.appVersion.value }}</td>
|
||||
<td>{{ pkg.platform || "-" }}/{{ pkg.arch || "-" }}</td>
|
||||
<td>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</td>
|
||||
<td><a :href="pkg.url || state.downloadUrl.value">下载</a></td>
|
||||
</tr>
|
||||
<tr v-if="state.loading.value"><td colspan="5">正在读取发布包...</td></tr>
|
||||
<tr v-else-if="state.packages.value.length === 0"><td colspan="5">暂无可见发布包。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="state.error.value && state.packages.value.length === 0" class="empty">发布信息读取失败:{{ state.error.value }}</p>
|
||||
</article>
|
||||
|
||||
<article class="panel wide">
|
||||
<div class="section-head"><h2>版本日志</h2><span class="badge good">自动同步</span></div>
|
||||
<div class="notice-list">
|
||||
<section v-for="notice in state.notices.value" :key="notice.version" class="notice-card">
|
||||
<BookOpenText :size="22" />
|
||||
<Card class="panel release-summary">
|
||||
<template #title>最新可用版本</template>
|
||||
<template #content>
|
||||
<div class="release-summary-body">
|
||||
<Sparkles :size="24" />
|
||||
<div>
|
||||
<strong>{{ notice.title || notice.version }}</strong>
|
||||
<p>{{ notice.message || notice.releaseNotes || notice.release_notes || "暂无详细说明。" }}</p>
|
||||
<span>{{ notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "-" }}</span>
|
||||
<span>当前版本</span>
|
||||
<strong>{{ state.appVersion.value }}</strong>
|
||||
<p>{{ state.latestNotice.value?.title || state.latestNotice.value?.message || "暂无版本摘要。" }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="release-summary-actions">
|
||||
<Tag :value="state.packages.value.length + ' 个发布包'" severity="info" rounded />
|
||||
<Button v-if="state.downloadUrl.value" as="a" rounded :href="state.downloadUrl.value">
|
||||
<Download :size="17" />下载最新版
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="panel release-summary">
|
||||
<template #title>首选安装包</template>
|
||||
<template #content>
|
||||
<div v-if="latestPackage" class="package-feature">
|
||||
<PackageCheck :size="24" />
|
||||
<div>
|
||||
<strong>{{ latestPackage.fileName || latestPackage.name || "发布包" }}</strong>
|
||||
<p>{{ latestPackage.platform || "通用平台" }} / {{ latestPackage.arch || "通用架构" }} · {{ state.formatBytes(latestPackage.sizeBytes || latestPackage.size || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="empty">暂无首选安装包。</p>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="panel wide">
|
||||
<template #title>发布包</template>
|
||||
<template #subtitle>{{ state.packages.value.length }} 个可用包</template>
|
||||
<template #content>
|
||||
<div v-if="state.packages.value.length" class="package-grid">
|
||||
<article v-for="pkg in state.packages.value" :key="pkg.url || pkg.fileName || pkg.name" class="package-card">
|
||||
<div>
|
||||
<Tag :value="pkg.version || state.appVersion.value" severity="info" rounded />
|
||||
<h3>{{ pkg.fileName || pkg.name || "发布包" }}</h3>
|
||||
<p>{{ pkg.platform || "通用平台" }} / {{ pkg.arch || "通用架构" }}</p>
|
||||
</div>
|
||||
<div class="package-meta">
|
||||
<span>{{ state.formatBytes(pkg.sizeBytes || pkg.size || 0) }}</span>
|
||||
<span>{{ state.formatDateTime(pkg.updatedAt || pkg.updated_at || pkg.createdAt || pkg.created_at || "") }}</span>
|
||||
</div>
|
||||
<Button as="a" rounded size="small" :href="pkg.url || state.downloadUrl.value">
|
||||
<Download :size="15" />下载
|
||||
</Button>
|
||||
</article>
|
||||
</div>
|
||||
<DataTable :value="state.packages.value" size="small" stripedRows responsiveLayout="scroll">
|
||||
<Column header="文件">
|
||||
<template #body="{ data }">{{ data.fileName || data.name || "-" }}</template>
|
||||
</Column>
|
||||
<Column header="版本">
|
||||
<template #body="{ data }"><Tag :value="data.version || state.appVersion.value" severity="info" /></template>
|
||||
</Column>
|
||||
<Column header="平台">
|
||||
<template #body="{ data }">{{ data.platform || "-" }}/{{ data.arch || "-" }}</template>
|
||||
</Column>
|
||||
<Column header="大小">
|
||||
<template #body="{ data }">{{ state.formatBytes(data.sizeBytes || data.size || 0) }}</template>
|
||||
</Column>
|
||||
<Column header="">
|
||||
<template #body="{ data }">
|
||||
<Button as="a" size="small" rounded :href="data.url || state.downloadUrl.value">
|
||||
<Download :size="15" />下载
|
||||
</Button>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<Skeleton v-if="state.loading.value" height="2.5rem" />
|
||||
<span v-else>暂无可见发布包。</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
<p v-if="state.error.value && state.packages.value.length === 0" class="empty">发布信息读取失败:{{ state.error.value }}</p>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="panel wide">
|
||||
<template #title>版本日志</template>
|
||||
<template #subtitle>自动同步</template>
|
||||
<template #content>
|
||||
<div v-if="state.notices.value.length" class="release-log-grid">
|
||||
<section v-for="notice in state.notices.value" :key="notice.id || notice.version || notice.title" class="notice-card release-log-card">
|
||||
<span class="timeline-marker"><BookOpenText :size="14" /></span>
|
||||
<div>
|
||||
<div class="release-log-head">
|
||||
<Tag :value="notice.version || '版本'" severity="info" rounded />
|
||||
<span>{{ state.formatDateTime(notice.publishedAt || notice.published_at || notice.updatedAt || notice.updated_at || "") }}</span>
|
||||
</div>
|
||||
<strong>{{ notice.title || notice.version || "版本更新" }}</strong>
|
||||
<p>{{ notice.message || notice.releaseNotes || notice.release_notes || "暂无详细说明。" }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-if="state.loading.value" class="empty">正在读取版本日志...</p>
|
||||
<p v-else-if="state.notices.value.length === 0" class="empty">暂无版本日志。</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,34 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2 } from "lucide-vue-next";
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { Activity, CheckCircle2, Gauge, TimerReset, WifiOff } from "lucide-vue-next";
|
||||
import Card from "primevue/card";
|
||||
import Column from "primevue/column";
|
||||
import DataTable from "primevue/datatable";
|
||||
import Message from "primevue/message";
|
||||
import ProgressBar from "primevue/progressbar";
|
||||
import Tag from "primevue/tag";
|
||||
import { usePortalState } from "../state";
|
||||
|
||||
const state = usePortalState();
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
void state.refresh();
|
||||
refreshTimer = window.setInterval(() => void state.refresh(), 20000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-heading">
|
||||
<p class="eyebrow">Sources</p>
|
||||
<h1>接口源健康</h1>
|
||||
<p>客户端可见接口目录和最近健康状态汇总。</p>
|
||||
<p>每 20 秒同步服务端健康检测结果,展示每个接口独立的实时状态、延迟和趋势。</p>
|
||||
</section>
|
||||
|
||||
<section class="panel wide">
|
||||
<div class="section-head"><h2>接口源可用性</h2><span class="badge">{{ state.sourceCount.value }} 个接口源</span></div>
|
||||
<div v-if="state.categories.value.length" class="source-board">
|
||||
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
|
||||
<div>
|
||||
<h3>{{ cat.name || cat.id }}</h3>
|
||||
<p>{{ cat.subcategories?.length || 0 }} 个数据源</p>
|
||||
</div>
|
||||
<div class="source-list">
|
||||
<span v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :class="['badge', state.statusTone(state.sourceStatus(src))]">
|
||||
<CheckCircle2 :size="13" />{{ src.name }}<small v-if="state.sourceStatus(src) === 'redirected'">重定向</small>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else-if="state.loading.value" class="empty">正在读取接口源目录...</p>
|
||||
<p v-else-if="state.error.value" class="empty">接口源状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-else class="empty">暂无客户端可见接口源。</p>
|
||||
</section>
|
||||
<Card class="panel wide">
|
||||
<template #title>接口源可用性</template>
|
||||
<template #subtitle>
|
||||
{{ state.sourceCount.value }} 个接口源,{{ state.availability.value }}% 可用率
|
||||
<span v-if="state.latestSourceCheckedAt.value"> · 最近检测 {{ state.formatDateTime(state.latestSourceCheckedAt.value) }}</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="latency-kpis">
|
||||
<article><Activity :size="18" /><span>可用接口</span><strong>{{ state.healthyCount.value }}/{{ state.sourceCount.value }}</strong></article>
|
||||
<article><Gauge :size="18" /><span>平均延迟</span><strong>{{ state.averageLatencyMs.value }}ms</strong></article>
|
||||
<article><TimerReset :size="18" /><span>P95 / 最大</span><strong>{{ state.p95LatencyMs.value }}ms / {{ state.maxLatencyMs.value }}ms</strong></article>
|
||||
<article><WifiOff :size="18" /><span>异常 / 慢接口</span><strong>{{ state.abnormalSourceCount.value }}/{{ state.slowSourceCount.value }}</strong></article>
|
||||
</div>
|
||||
<ProgressBar :value="state.availability.value" class="availability-bar" />
|
||||
<Message v-if="state.errors.value['/api/client/sources']" severity="warn" :closable="false" class="state-message-inline">
|
||||
接口源刷新失败,已保留上一次数据:{{ state.errors.value['/api/client/sources'] }}
|
||||
</Message>
|
||||
<div v-if="state.categories.value.length" class="source-board">
|
||||
<section v-for="cat in state.categories.value" :key="cat.id || cat.name" class="source-group">
|
||||
<div>
|
||||
<h3>{{ cat.name || cat.id }}</h3>
|
||||
<p>{{ cat.subcategories?.length || 0 }} 个数据源</p>
|
||||
</div>
|
||||
<div class="source-list">
|
||||
<Tag v-for="src in cat.subcategories || []" :key="src.id || src.sourceId" :severity="state.statusSeverity(state.sourceStatus(src))">
|
||||
<CheckCircle2 :size="13" />{{ src.name }}
|
||||
<small>{{ state.sourceLatency(src) ?? "-" }}ms</small>
|
||||
<small v-if="state.sourceStatus(src) === 'redirected'">重定向</small>
|
||||
</Tag>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p v-else-if="state.loading.value" class="empty">正在读取接口源目录...</p>
|
||||
<p v-else-if="state.error.value" class="empty">接口源状态读取失败:{{ state.error.value }}</p>
|
||||
<p v-else class="empty">暂无客户端可见接口源。</p>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="panel wide latency-panel">
|
||||
<template #title>实时接口延迟表</template>
|
||||
<template #subtitle>以服务端最近一次独立健康检测结果为准,页面每 20 秒刷新。</template>
|
||||
<template #content>
|
||||
<DataTable :value="state.latencyRows.value" size="small" stripedRows responsiveLayout="scroll" sortField="lastCheckedAt" :sortOrder="-1">
|
||||
<Column field="name" header="接口">
|
||||
<template #body="{ data }">
|
||||
<div class="latency-name">
|
||||
<strong>{{ data.name || data.sourceId || data.id }}</strong>
|
||||
<span>{{ data.categoryName || data.categoryId || "未分类" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="状态">
|
||||
<template #body="{ data }">
|
||||
<Tag :severity="state.statusSeverity(data.status)" :value="data.status" rounded />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="latencyMs" header="延迟">
|
||||
<template #body="{ data }">
|
||||
<strong :class="['latency-value', Number(data.latencyMs) >= 1500 ? 'slow' : '']">{{ data.latencyMs ?? "-" }}ms</strong>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="lastCheckedAt" header="最近检测">
|
||||
<template #body="{ data }">{{ state.formatDateTime(data.lastCheckedAt) }}</template>
|
||||
</Column>
|
||||
<Column header="延迟趋势">
|
||||
<template #body="{ data }">
|
||||
<svg class="sparkline" viewBox="0 0 120 34" role="img" aria-label="接口延迟趋势">
|
||||
<polyline :points="state.sparklinePoints(data)" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<span>暂无接口延迟数据。</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { applyDocumentBranding, normalizeBranding } from "./branding";
|
||||
|
||||
const bootstrap = ref<any>(null);
|
||||
const releases = ref<any>(null);
|
||||
@@ -6,6 +7,7 @@ const sources = ref<any>(null);
|
||||
const notices = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const errors = ref<Record<string, string>>({});
|
||||
const loadedAt = ref("");
|
||||
const requestState = ref<Record<string, "idle" | "loading" | "ready" | "error">>({
|
||||
bootstrap: "idle",
|
||||
@@ -45,20 +47,48 @@ export function usePortalState() {
|
||||
const packages = computed(() => releases.value?.packages || bootstrap.value?.release?.packages || []);
|
||||
const categories = computed(() => sources.value?.categories || bootstrap.value?.sources?.categories || []);
|
||||
const latestNotice = computed(() => notices.value[0] || releases.value?.latest_notice || bootstrap.value?.release?.latest_notice || null);
|
||||
const sourceCount = computed(() => categories.value.reduce((total: number, cat: any) => total + (cat.subcategories?.length || 0), 0));
|
||||
const healthyCount = computed(() => categories.value.reduce((total: number, cat: any) => {
|
||||
return total + (cat.subcategories || []).filter((item: any) => sourceStatus(item) === "ok").length;
|
||||
}, 0));
|
||||
const sourceRows = computed(() => categories.value.flatMap((cat: any) => (cat.subcategories || []).map((src: any) => ({
|
||||
...src,
|
||||
categoryId: cat.id || cat.categoryId || "",
|
||||
categoryName: cat.name || cat.id || cat.categoryId || "未分类",
|
||||
status: sourceStatus(src),
|
||||
latencyMs: sourceLatency(src),
|
||||
lastCheckedAt: sourceLastChecked(src),
|
||||
lastError: sourceError(src),
|
||||
history: sourceHistory(src),
|
||||
}))));
|
||||
const sourceCount = computed(() => sourceRows.value.length);
|
||||
const healthyCount = computed(() => sourceRows.value.filter((item: any) => ["ok", "redirected"].includes(item.status)).length);
|
||||
const availability = computed(() => sourceCount.value ? Math.round((healthyCount.value / sourceCount.value) * 100) : 0);
|
||||
const latencyRows = computed(() => sourceRows.value.slice().sort((a: any, b: any) => {
|
||||
const left = a.lastCheckedAt || "";
|
||||
const right = b.lastCheckedAt || "";
|
||||
if (left !== right) return right.localeCompare(left);
|
||||
return Number(b.latencyMs ?? -1) - Number(a.latencyMs ?? -1);
|
||||
}));
|
||||
const latencyValues = computed(() => latencyRows.value.map((item: any) => Number(item.latencyMs)).filter((item: number) => Number.isFinite(item) && item >= 0));
|
||||
const averageLatencyMs = computed(() => latencyValues.value.length ? Math.round(latencyValues.value.reduce((sum: number, item: number) => sum + item, 0) / latencyValues.value.length) : 0);
|
||||
const maxLatencyMs = computed(() => latencyValues.value.length ? Math.max(...latencyValues.value) : 0);
|
||||
const p95LatencyMs = computed(() => {
|
||||
if (!latencyValues.value.length) return 0;
|
||||
const sorted = latencyValues.value.slice().sort((a: number, b: number) => a - b);
|
||||
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)] || 0;
|
||||
});
|
||||
const slowSourceCount = computed(() => latencyRows.value.filter((item: any) => Number(item.latencyMs) >= 1500).length);
|
||||
const abnormalSourceCount = computed(() => latencyRows.value.filter((item: any) => !["ok", "redirected"].includes(item.status)).length);
|
||||
const latestSourceCheckedAt = computed(() => latencyRows.value.map((item: any) => item.lastCheckedAt).filter(Boolean).sort().pop() || "");
|
||||
const downloadUrl = computed(() => releases.value?.download_url || bootstrap.value?.release?.download_url || packages.value[0]?.url || "");
|
||||
const appVersion = computed(() => releases.value?.app_version || bootstrap.value?.release?.app_version || latestNotice.value?.version || "未发布");
|
||||
const serviceVersion = computed(() => bootstrap.value?.serviceVersion || "-");
|
||||
const branding = computed(() => ({
|
||||
siteIconUrl: bootstrap.value?.branding?.siteIconUrl || "/assets/favicon.ico",
|
||||
developerAvatarUrl: bootstrap.value?.branding?.developerAvatarUrl || "/assets/developer-avatar.png",
|
||||
developerName: bootstrap.value?.branding?.developerName || "YMhut",
|
||||
feedbackEmail: bootstrap.value?.branding?.feedbackEmail || "support@ymhut.cn",
|
||||
}));
|
||||
const branding = computed(() => normalizeBranding(bootstrap.value?.branding));
|
||||
const portalTitle = computed(() => branding.value.portalTitle);
|
||||
const portalSubtitle = computed(() => branding.value.portalSubtitle);
|
||||
const logoUrl = computed(() => branding.value.logoUrl || branding.value.siteIconUrl);
|
||||
const failedRequests = computed(() => Object.entries(errors.value).map(([path, message]) => ({
|
||||
path,
|
||||
label: endpointLabels[path] || path,
|
||||
message,
|
||||
})));
|
||||
const isReady = computed(() => loaded && !loading.value && !error.value);
|
||||
const hasPartialData = computed(() => Boolean(bootstrap.value || releases.value || sources.value || notices.value.length));
|
||||
const releasesEmpty = computed(() => !loading.value && packages.value.length === 0 && notices.value.length === 0);
|
||||
@@ -68,6 +98,7 @@ export function usePortalState() {
|
||||
if (loaded && !force) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
errors.value = {};
|
||||
requestState.value = { bootstrap: "loading", releases: "loading", sources: "loading", notices: "loading" };
|
||||
try {
|
||||
const [bootstrapData, releaseData, sourceData, noticeData] = await Promise.allSettled([
|
||||
@@ -79,26 +110,31 @@ export function usePortalState() {
|
||||
if (bootstrapData.status === "fulfilled") {
|
||||
bootstrap.value = bootstrapData.value;
|
||||
requestState.value.bootstrap = "ready";
|
||||
applyDocumentBranding(branding.value, "portal");
|
||||
} else {
|
||||
requestState.value.bootstrap = "error";
|
||||
errors.value["/api/client/bootstrap"] = failureMessage(bootstrapData.reason);
|
||||
}
|
||||
if (releaseData.status === "fulfilled") {
|
||||
releases.value = releaseData.value;
|
||||
requestState.value.releases = "ready";
|
||||
} else {
|
||||
requestState.value.releases = "error";
|
||||
errors.value["/api/client/releases"] = failureMessage(releaseData.reason);
|
||||
}
|
||||
if (sourceData.status === "fulfilled") {
|
||||
sources.value = sourceData.value;
|
||||
requestState.value.sources = "ready";
|
||||
} else {
|
||||
requestState.value.sources = "error";
|
||||
errors.value["/api/client/sources"] = failureMessage(sourceData.reason);
|
||||
}
|
||||
if (noticeData.status === "fulfilled") {
|
||||
notices.value = noticeData.value.items || [];
|
||||
requestState.value.notices = "ready";
|
||||
} else {
|
||||
requestState.value.notices = "error";
|
||||
errors.value["/api/client/notices"] = failureMessage(noticeData.reason);
|
||||
}
|
||||
const firstFailure = [bootstrapData, releaseData, sourceData, noticeData].find((item) => item.status === "rejected") as PromiseRejectedResult | undefined;
|
||||
if (firstFailure && !hasPartialData.value) error.value = failureMessage(firstFailure.reason);
|
||||
@@ -107,6 +143,7 @@ export function usePortalState() {
|
||||
} catch (err) {
|
||||
error.value = failureMessage(err);
|
||||
} finally {
|
||||
applyDocumentBranding(branding.value, "portal");
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -118,26 +155,47 @@ export function usePortalState() {
|
||||
notices,
|
||||
loading,
|
||||
error,
|
||||
errors,
|
||||
failedRequests,
|
||||
loadedAt,
|
||||
requestState,
|
||||
packages,
|
||||
categories,
|
||||
latestNotice,
|
||||
sourceRows,
|
||||
latencyRows,
|
||||
sourceCount,
|
||||
healthyCount,
|
||||
availability,
|
||||
averageLatencyMs,
|
||||
maxLatencyMs,
|
||||
p95LatencyMs,
|
||||
slowSourceCount,
|
||||
abnormalSourceCount,
|
||||
latestSourceCheckedAt,
|
||||
downloadUrl,
|
||||
appVersion,
|
||||
serviceVersion,
|
||||
branding,
|
||||
portalTitle,
|
||||
portalSubtitle,
|
||||
logoUrl,
|
||||
isReady,
|
||||
hasPartialData,
|
||||
releasesEmpty,
|
||||
sourcesEmpty,
|
||||
load,
|
||||
refresh: () => load(true),
|
||||
sourceStatus,
|
||||
sourceLatency,
|
||||
sourceLastChecked,
|
||||
sourceError,
|
||||
statusTone,
|
||||
statusSeverity,
|
||||
formatBytes,
|
||||
formatDateTime,
|
||||
sourceHistory,
|
||||
sparklinePoints,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,6 +203,41 @@ export function sourceStatus(item: any) {
|
||||
return item.health?.status || item.lastStatus || "unknown";
|
||||
}
|
||||
|
||||
export function sourceLatency(item: any) {
|
||||
const value = firstFiniteNumber(item.health?.latencyMs, item.health?.latency_ms, item.lastLatencyMs, item.last_latency_ms, item.latencyMs, item.latency_ms);
|
||||
return value === null ? null : value;
|
||||
}
|
||||
|
||||
export function sourceLastChecked(item: any) {
|
||||
return item.health?.lastCheckedAt || item.health?.last_checked_at || item.lastCheckedAt || item.last_checked_at || item.checkedAt || item.checked_at || "";
|
||||
}
|
||||
|
||||
export function sourceError(item: any) {
|
||||
return item.health?.lastError || item.health?.last_error || item.lastError || item.last_error || item.error || "";
|
||||
}
|
||||
|
||||
export function sourceHistory(item: any) {
|
||||
const history = item.health?.history || item.history || [];
|
||||
return Array.isArray(history) ? history : [];
|
||||
}
|
||||
|
||||
export function sparklinePoints(item: any) {
|
||||
const values = sourceHistory(item)
|
||||
.map((entry: any) => firstFiniteNumber(entry.latencyMs, entry.latency_ms, entry.latency))
|
||||
.filter((entry: number | null): entry is number => entry !== null);
|
||||
const series = values.length ? values : [sourceLatency(item) ?? 0];
|
||||
const max = Math.max(1, ...series);
|
||||
if (series.length === 1) {
|
||||
const y = 30 - (series[0] / max) * 24;
|
||||
return `0,${y.toFixed(1)} 120,${y.toFixed(1)}`;
|
||||
}
|
||||
return series.map((value: number, index: number) => {
|
||||
const x = (index / (series.length - 1)) * 120;
|
||||
const y = 30 - (value / max) * 24;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
export function statusTone(status: string) {
|
||||
const value = String(status || "").toLowerCase();
|
||||
if (["ok", "redirected", "sqlite", "mysql", "online", "ready"].includes(value)) return "good";
|
||||
@@ -153,6 +246,14 @@ export function statusTone(status: string) {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export function statusSeverity(status: string) {
|
||||
const tone = statusTone(status);
|
||||
if (tone === "good") return "success";
|
||||
if (tone === "warn") return "warn";
|
||||
if (tone === "bad") return "danger";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
export function formatBytes(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
@@ -164,3 +265,18 @@ export function formatBytes(value: number) {
|
||||
}
|
||||
return `${next.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export 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 firstFiniteNumber(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric >= 0) return numeric;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,470 +1,396 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: "Microsoft YaHei UI", "Segoe UI", Arial, sans-serif;
|
||||
color: #172033;
|
||||
background: #f5f7f4;
|
||||
--ink: #172033;
|
||||
--muted: #63718a;
|
||||
--soft: #f5f7f4;
|
||||
--panel: rgba(255, 255, 255, 0.82);
|
||||
--panel-strong: #ffffff;
|
||||
--line: rgba(112, 132, 170, 0.18);
|
||||
--line-strong: rgba(94, 114, 158, 0.28);
|
||||
--primary: #1f6f5b;
|
||||
--primary-dark: #155241;
|
||||
--accent: #d99227;
|
||||
color: #020617;
|
||||
background: #f8fafc;
|
||||
--ink: #020617;
|
||||
--muted: #64748b;
|
||||
--panel: #ffffff;
|
||||
--line: #e2e8f0;
|
||||
--line-strong: #cbd5e1;
|
||||
--primary: #0369a1;
|
||||
--primary-dark: #0f172a;
|
||||
--good: #059669;
|
||||
--warn: #b7791f;
|
||||
--warn: #b45309;
|
||||
--bad: #dc2626;
|
||||
--shadow: 0 22px 65px rgba(31, 48, 40, 0.12);
|
||||
--shadow: 0 16px 44px rgba(15, 23, 42, 0.08);
|
||||
--ease: cubic-bezier(.2,.8,.2,1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { min-width: 320px; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: #f6f8f4;
|
||||
}
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: rgba(31, 111, 91, 0.025);
|
||||
}
|
||||
html { min-width: 320px; max-width: 100%; overflow-x: clip; }
|
||||
body { margin: 0; min-width: 320px; background: #f8fafc; max-width: 100%; overflow-x: clip; }
|
||||
a { color: inherit; }
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
p { color: var(--muted); line-height: 1.7; font-size: 15px; }
|
||||
|
||||
.portal-shell {
|
||||
position: relative;
|
||||
min-height: 100dvh;
|
||||
padding: 18px clamp(14px, 2.4vw, 28px) 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topnav {
|
||||
.topnav.p-toolbar {
|
||||
position: sticky;
|
||||
z-index: 20;
|
||||
top: 14px;
|
||||
min-height: 64px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto 22px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: 0 16px 42px rgba(62, 87, 130, 0.12);
|
||||
min-height: 64px;
|
||||
margin: 0 auto 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 12px 34px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(18px);
|
||||
gap: 12px;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 5px 12px 5px 6px;
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
font-weight: 900;
|
||||
}
|
||||
.brand span {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: #10231d;
|
||||
box-shadow: 0 12px 26px rgba(31, 111, 91, 0.22);
|
||||
}
|
||||
.brand img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
.brand strong { letter-spacing: 0; }
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: rgba(248, 250, 252, 0.92);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
padding: 5px;
|
||||
}
|
||||
.brand img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.brand strong { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nav-links { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
|
||||
.nav-links a {
|
||||
min-height: 38px;
|
||||
min-height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
color: #53627d;
|
||||
padding: 7px 11px;
|
||||
color: #475569;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
transition: transform 0.18s var(--ease), background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
.nav-links a:hover, .nav-links a.active {
|
||||
color: var(--primary-dark);
|
||||
background: rgba(31, 111, 91, 0.10);
|
||||
transform: translateY(-1px);
|
||||
.nav-links a:hover, .nav-links a.active { color: #075985; background: #e0f2fe; }
|
||||
.loading-bar, .state-message, .state-ready {
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto 14px;
|
||||
}
|
||||
.loading-bar { height: 4px; border-radius: 999px; overflow: hidden; }
|
||||
.state-ready {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
width: min(1180px, 100%);
|
||||
min-height: 520px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 22px;
|
||||
align-items: stretch;
|
||||
border: 1px solid rgba(255, 255, 255, 0.70);
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: var(--shadow);
|
||||
padding: clamp(28px, 5vw, 58px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero::after { content: none; }
|
||||
.hero-copy {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 780px;
|
||||
align-self: center;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
color: var(--primary-dark);
|
||||
margin: 0 0 10px;
|
||||
color: var(--primary);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 18px;
|
||||
max-width: 900px;
|
||||
color: #12213a;
|
||||
font-size: clamp(36px, 6vw, 68px);
|
||||
line-height: 1.02;
|
||||
.hero {
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 350px;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.hero-copy, .page-heading, .panel.p-card, .release-card.p-card, .metric.p-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.hero-copy {
|
||||
min-height: 430px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: clamp(28px, 5vw, 56px);
|
||||
}
|
||||
.hero-copy h1, .page-heading h1 {
|
||||
margin: 0 0 16px;
|
||||
max-width: 920px;
|
||||
color: #0f172a;
|
||||
font-size: clamp(34px, 5.2vw, 56px);
|
||||
line-height: 1.04;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
h2, h3, p { margin-top: 0; }
|
||||
p {
|
||||
color: var(--muted);
|
||||
line-height: 1.85;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hero-copy p { max-width: 690px; font-size: 17px; }
|
||||
|
||||
.actions {
|
||||
.hero-copy p { max-width: 760px; font-size: 16px; }
|
||||
.actions, .hero-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.actions { margin-top: 24px; }
|
||||
.hero-tags { margin-top: 18px; }
|
||||
.actions .p-button, .actions a.p-button, a.p-button, .p-button {
|
||||
gap: 8px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
.hero-tags span {
|
||||
border: 1px solid rgba(31, 111, 91, 0.16);
|
||||
border-radius: 999px;
|
||||
padding: 7px 11px;
|
||||
color: #355075;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.button {
|
||||
min-height: 46px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid rgba(118, 137, 178, 0.22);
|
||||
border-radius: 999px;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
color: #263856;
|
||||
font-weight: 900;
|
||||
box-shadow: 0 10px 26px rgba(65, 88, 140, 0.10);
|
||||
border-radius: 999px;
|
||||
transition: transform 0.18s var(--ease), box-shadow 0.18s ease, background-color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.button:hover {
|
||||
.actions .p-button:hover, .actions a.p-button:hover, a.p-button:hover, .p-button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: #fff;
|
||||
box-shadow: 0 16px 36px rgba(65, 88, 140, 0.16);
|
||||
}
|
||||
.button.primary {
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
background: #10231d;
|
||||
box-shadow: 0 16px 34px rgba(31, 111, 91, 0.24);
|
||||
box-shadow: 0 10px 24px rgba(3, 105, 161, 0.16);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.release-card, .panel, .metric {
|
||||
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||
border-radius: 24px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 14px 42px rgba(65, 88, 140, 0.11);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.release-card, .panel, .metric, .source-group, .notice-card, .route-list a {
|
||||
transition: transform 0.22s var(--ease), border-color 0.22s ease, box-shadow 0.22s ease, background-color 0.22s ease;
|
||||
}
|
||||
.release-card:hover, .panel:hover, .metric:hover, .source-group:hover, .notice-card:hover, .route-list a:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 18px 46px rgba(31, 48, 40, 0.13);
|
||||
}
|
||||
.release-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
align-self: center;
|
||||
.release-card.p-card {
|
||||
align-self: stretch;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
}
|
||||
.release-card .p-card-body, .release-card .p-card-content { height: 100%; }
|
||||
.release-card .p-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
.release-card span { color: var(--muted); font-weight: 800; }
|
||||
.release-card .live-dot {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--good);
|
||||
border: 1px solid rgba(16, 185, 129, 0.22);
|
||||
border-radius: 999px;
|
||||
padding: 5px 9px;
|
||||
background: rgba(209, 250, 229, 0.66);
|
||||
}
|
||||
.release-card .live-dot::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.13);
|
||||
}
|
||||
.release-card strong {
|
||||
color: #0f172a;
|
||||
display: block;
|
||||
font-size: 44px;
|
||||
font-size: clamp(30px, 4vw, 44px);
|
||||
line-height: 1.05;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.release-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.release-meta span, .badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.release-meta { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.release-meta span {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 10px;
|
||||
color: #44536e;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
background: #f8fafc;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
.metric-grid, .content-grid, .page-heading, .compat-accordion {
|
||||
width: min(1180px, 100%);
|
||||
margin: 18px auto 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.metric-grid {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
gap: 12px;
|
||||
}
|
||||
.metric {
|
||||
min-height: 132px;
|
||||
display: grid;
|
||||
.metric .p-card-content {
|
||||
min-height: 116px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
}
|
||||
.metric svg { color: var(--primary); }
|
||||
.metric span { color: var(--muted); font-weight: 800; }
|
||||
.metric strong { color: #15233b; font-size: 30px; overflow-wrap: anywhere; }
|
||||
.metric strong { color: #0f172a; font-size: 28px; overflow-wrap: anywhere; }
|
||||
|
||||
.content-grid {
|
||||
width: min(1180px, 100%);
|
||||
margin: 18px auto 0;
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.12fr 0.88fr;
|
||||
grid-template-columns: 1.08fr 0.92fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.panel {
|
||||
padding: 22px;
|
||||
}
|
||||
.panel.wide { grid-column: 1 / -1; }
|
||||
.page-heading {
|
||||
width: min(1180px, 100%);
|
||||
margin: 0 auto 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.74);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: var(--shadow);
|
||||
margin-top: 0;
|
||||
margin-bottom: 18px;
|
||||
padding: clamp(24px, 4vw, 42px);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.page-heading h1 { font-size: clamp(32px, 4.6vw, 52px); }
|
||||
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.section-head h2 { margin: 0; color: #14223a; }
|
||||
.section-head a {
|
||||
color: var(--primary-dark);
|
||||
font-weight: 900;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
.page-heading h1 { font-size: clamp(30px, 4vw, 46px); }
|
||||
.route-list { display: grid; gap: 10px; }
|
||||
.route-list a {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
padding: 14px;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.18s ease, background-color 0.18s ease;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
border-bottom: 1px solid rgba(112, 132, 170, 0.18);
|
||||
padding: 11px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.muted, .empty { color: var(--muted); }
|
||||
.empty.strong { font-weight: 900; color: var(--bad); }
|
||||
.route-list a:hover { border-color: var(--line-strong); background: #f1f5f9; }
|
||||
.route-list strong { color: #0f172a; }
|
||||
.route-list span { color: var(--muted); font-size: 13px; }
|
||||
.notice-list { display: grid; gap: 12px; }
|
||||
.notice-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
border: 1px solid rgba(112, 132, 170, 0.15);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #f8fafc;
|
||||
padding: 14px;
|
||||
}
|
||||
.notice-card svg { color: var(--primary); }
|
||||
.notice-card strong { display: block; margin-bottom: 6px; overflow-wrap: anywhere; }
|
||||
.notice-card p { margin-bottom: 8px; font-size: 14px; line-height: 1.65; }
|
||||
.notice-card p { margin-bottom: 6px; font-size: 14px; }
|
||||
.notice-card span { color: var(--muted); font-size: 13px; }
|
||||
.empty { color: var(--muted); }
|
||||
.empty.strong { color: var(--bad); font-weight: 900; }
|
||||
|
||||
.feedback-panel { max-width: 780px; margin: 0 auto; }
|
||||
.feedback-box {
|
||||
.availability-bar { margin-bottom: 16px; }
|
||||
.latency-kpis, .package-grid, .feedback-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
border: 1px solid rgba(112, 132, 170, 0.24);
|
||||
.latency-kpis article, .package-card, .feedback-fields article, .release-summary-body, .package-feature {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: #172033;
|
||||
padding: 10px 14px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus {
|
||||
border-color: rgba(31, 111, 91, 0.58);
|
||||
box-shadow: 0 0 0 4px rgba(31, 111, 91, 0.12);
|
||||
}
|
||||
.source-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.source-group {
|
||||
border: 1px solid rgba(112, 132, 170, 0.16);
|
||||
border-radius: 22px;
|
||||
padding: 16px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
.source-group h3 { margin-bottom: 2px; color: #14223a; }
|
||||
.source-group p { margin-bottom: 10px; font-size: 14px; }
|
||||
.source-list {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.badge.good { color: var(--good); background: rgba(209, 250, 229, 0.78); border-color: rgba(16, 185, 129, 0.28); }
|
||||
.badge.warn { color: var(--warn); background: rgba(254, 243, 199, 0.82); border-color: rgba(245, 158, 11, 0.28); }
|
||||
.badge.bad { color: var(--bad); background: rgba(254, 226, 226, 0.82); border-color: rgba(239, 68, 68, 0.26); }
|
||||
.route-list { display: grid; gap: 10px; }
|
||||
.route-list a {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid rgba(112, 132, 170, 0.16);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.70);
|
||||
background: #f8fafc;
|
||||
padding: 14px;
|
||||
text-decoration: none;
|
||||
font-weight: 900;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, background-color 0.18s ease;
|
||||
}
|
||||
.route-list a:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(31, 111, 91, 0.30);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
.latency-kpis article {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.route-list span { color: var(--muted); font-size: 13px; font-weight: 700; }
|
||||
.state-banner {
|
||||
width: min(1180px, 100%);
|
||||
margin: 12px auto;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
.latency-kpis svg, .release-summary svg, .timeline-marker svg { color: var(--primary); }
|
||||
.latency-kpis span, .package-meta, .feedback-fields span, .release-summary-body span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.latency-kpis strong, .feedback-fields strong { color: #0f172a; overflow-wrap: anywhere; }
|
||||
.state-message-inline { margin-bottom: 14px; }
|
||||
.latency-name { display: grid; gap: 3px; }
|
||||
.latency-name span, .table-note { color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.latency-value { color: var(--good); white-space: nowrap; }
|
||||
.latency-value.slow { color: var(--warn); }
|
||||
.sparkline {
|
||||
width: 120px;
|
||||
max-width: 100%;
|
||||
height: 34px;
|
||||
display: block;
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, rgba(224, 242, 254, 0.72), rgba(255, 255, 255, 0.9));
|
||||
padding: 4px;
|
||||
}
|
||||
.source-board { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.source-group {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.source-group h3 { margin-bottom: 2px; color: #0f172a; }
|
||||
.source-group p { margin-bottom: 12px; font-size: 14px; }
|
||||
.source-list { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||
.source-list .p-tag { gap: 4px; }
|
||||
|
||||
.release-summary .p-card-content { display: grid; gap: 14px; }
|
||||
.release-summary-body, .package-feature {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.release-summary-body strong {
|
||||
display: block;
|
||||
color: #0f172a;
|
||||
font-size: 30px;
|
||||
line-height: 1.08;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.release-summary-body p, .package-feature p { margin: 6px 0 0; }
|
||||
.release-summary-actions, .package-meta, .feedback-status-head, .feedback-result-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
box-shadow: 0 12px 30px rgba(65, 88, 140, 0.10);
|
||||
}
|
||||
.error { color: var(--bad); }
|
||||
.loading { color: var(--muted); }
|
||||
.ready { color: var(--good); }
|
||||
.package-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.package-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: space-between;
|
||||
transition: transform 0.18s var(--ease), box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.package-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(3, 105, 161, 0.28);
|
||||
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.10);
|
||||
}
|
||||
.package-card h3 { margin: 10px 0 4px; color: #0f172a; overflow-wrap: anywhere; }
|
||||
.release-log-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.release-log-card {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.release-log-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.release-log-head span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.timeline-marker {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
.feedback-panel, .feedback-result { max-width: 780px; margin: 0 auto 18px; }
|
||||
.feedback-box { margin: 14px 0; }
|
||||
.feedback-status-head { margin-bottom: 14px; }
|
||||
.feedback-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.feedback-fields article { display: grid; gap: 6px; }
|
||||
.feedback-fields strong {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.compat-accordion { margin-top: 12px; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.topnav {
|
||||
position: static;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
border-radius: 24px;
|
||||
}
|
||||
.nav-links { justify-content: flex-start; }
|
||||
.hero, .content-grid, .metric-grid, .source-board { grid-template-columns: 1fr; }
|
||||
.hero { min-height: auto; }
|
||||
.feedback-box { grid-template-columns: 1fr; }
|
||||
table { min-width: 680px; }
|
||||
.panel { overflow-x: auto; }
|
||||
@media (max-width: 960px) {
|
||||
.topnav.p-toolbar { align-items: stretch; border-radius: 18px; }
|
||||
.topnav .p-toolbar-group-center { width: 100%; }
|
||||
.hero, .content-grid, .source-board, .package-grid, .latency-kpis, .feedback-fields { grid-template-columns: 1fr; }
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.portal-shell { padding-inline: 10px; }
|
||||
.hero, .page-heading { border-radius: 24px; padding: 24px; }
|
||||
h1 { font-size: 36px; }
|
||||
.actions .button { width: 100%; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { transition: none !important; animation: none !important; scroll-behavior: auto !important; }
|
||||
.portal-shell { padding-inline: 12px; }
|
||||
.topnav.p-toolbar { top: 8px; }
|
||||
.brand strong { max-width: 120px; }
|
||||
.nav-links { justify-content: flex-start; }
|
||||
.hero-copy { min-height: auto; padding: 24px; }
|
||||
.metric-grid { grid-template-columns: 1fr; }
|
||||
.feedback-box.p-inputgroup { display: grid; grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
"name": "ymhut-unified-setup",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.5",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16"
|
||||
},
|
||||
@@ -485,6 +488,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",
|
||||
@@ -1146,6 +1217,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",
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.3.5",
|
||||
"vite": "^6.3.5",
|
||||
"vue": "^3.5.16"
|
||||
},
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { createApp } from "vue";
|
||||
import PrimeVue from "primevue/config";
|
||||
import Aura from "@primeuix/themes/aura";
|
||||
import "primeicons/primeicons.css";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
createApp(App)
|
||||
.use(PrimeVue, {
|
||||
ripple: true,
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: ".setup-dark",
|
||||
},
|
||||
},
|
||||
locale: {
|
||||
accept: "确定",
|
||||
reject: "取消",
|
||||
clear: "清除",
|
||||
apply: "应用",
|
||||
},
|
||||
})
|
||||
.mount("#app");
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
"fullInstaller": {
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.10.exe",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.10.exe",
|
||||
"sha256": "d1aa60d2d96a73fba63510de276957e8ee8b048fc856628017449ab192798435",
|
||||
"size": 113538352,
|
||||
"sha256": "c9851d352c7db50cde75425fd5a7efabec188901b730401cfcfb610e5f3efc52",
|
||||
"size": 113643344,
|
||||
"version": "2.0.7.10"
|
||||
},
|
||||
"msix": {
|
||||
"fileName": "YMhutBox_2.0.7.10_x64.msix",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.10_x64.msix",
|
||||
"sha256": "a6a886f2340e1c874a276c7f7eb3685e2821d7e246f967f90506f0af06bd2b75",
|
||||
"size": 259980364,
|
||||
"sha256": "38c6672106baec2233d32fa6de6ddd6d0b2c89db4391a22dd316b3684d2c1e8c",
|
||||
"size": 260259241,
|
||||
"version": "2.0.7.10"
|
||||
},
|
||||
"appInstaller": {
|
||||
@@ -32,15 +32,15 @@
|
||||
"fullInstaller": {
|
||||
"fileName": "YMhut_Box_WinUI_Setup_2.0.7.10.exe",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhut_Box_WinUI_Setup_2.0.7.10.exe",
|
||||
"sha256": "d1aa60d2d96a73fba63510de276957e8ee8b048fc856628017449ab192798435",
|
||||
"size": 113538352,
|
||||
"sha256": "c9851d352c7db50cde75425fd5a7efabec188901b730401cfcfb610e5f3efc52",
|
||||
"size": 113643344,
|
||||
"version": "2.0.7.10"
|
||||
},
|
||||
"msix": {
|
||||
"fileName": "YMhutBox_2.0.7.10_x64.msix",
|
||||
"url": "https://update.ymhut.cn/downloads/YMhutBox_2.0.7.10_x64.msix",
|
||||
"sha256": "a6a886f2340e1c874a276c7f7eb3685e2821d7e246f967f90506f0af06bd2b75",
|
||||
"size": 259980364,
|
||||
"sha256": "38c6672106baec2233d32fa6de6ddd6d0b2c89db4391a22dd316b3684d2c1e8c",
|
||||
"size": 260259241,
|
||||
"version": "2.0.7.10"
|
||||
},
|
||||
"appInstaller": {
|
||||
@@ -56,5 +56,5 @@
|
||||
"updateInfo": "The official update-info catalog only describes the full offline installer, MSIX, and appinstaller artifacts.",
|
||||
"distribution": "The update channel publishes the full offline installer, MSIX, and appinstaller artifacts."
|
||||
},
|
||||
"createdAt": "2026-07-06T13:50:47.6669916Z"
|
||||
"createdAt": "2026-07-07T03:44:58.1494699Z"
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace YMhut.Box.Core.Updates;
|
||||
|
||||
public sealed record UpdateNoticeDocument(
|
||||
string Version,
|
||||
string Build,
|
||||
string Channel,
|
||||
string Title,
|
||||
string Summary,
|
||||
DateTimeOffset? PublishedAt,
|
||||
bool Mandatory,
|
||||
IReadOnlyList<UpdateNoticeCategory> Categories,
|
||||
IReadOnlyList<UpdateNoticeSection> Sections,
|
||||
IReadOnlyList<UpdateNoticeSection> History,
|
||||
string RawMarkdown,
|
||||
string RawText)
|
||||
{
|
||||
public bool HasStructuredContent => Sections.Count > 0 || History.Count > 0;
|
||||
}
|
||||
|
||||
public sealed record UpdateNoticeCategory(
|
||||
string Id,
|
||||
string Name,
|
||||
string Icon);
|
||||
|
||||
public sealed record UpdateNoticeSection(
|
||||
string Id,
|
||||
string Title,
|
||||
string Icon,
|
||||
IReadOnlyList<UpdateNoticeItem> Items);
|
||||
|
||||
public sealed record UpdateNoticeItem(
|
||||
string Title,
|
||||
string Body,
|
||||
string Kind = "",
|
||||
string Tag = "");
|
||||
|
||||
public static class UpdateNoticeDocumentBuilder
|
||||
{
|
||||
public static UpdateNoticeDocument FromJson(JsonElement root, JsonElement latest)
|
||||
{
|
||||
var version = FirstNonEmpty(
|
||||
GetString(root, "latestVersion"),
|
||||
GetString(latest, "version"),
|
||||
GetString(latest, "app_version"),
|
||||
GetString(latest, "appVersion"),
|
||||
GetString(root, "version"),
|
||||
GetString(root, "app_version"));
|
||||
var build = FirstNonEmpty(GetString(latest, "build"), GetString(latest, "build_number"), GetString(root, "build"));
|
||||
var channel = FirstNonEmpty(GetString(latest, "channel"), GetString(root, "channel"), "stable");
|
||||
var title = FirstNonEmpty(GetString(latest, "title"), GetString(root, "title"), version);
|
||||
var message = FirstNonEmpty(
|
||||
GetString(latest, "message_md"),
|
||||
GetString(latest, "messageMarkdown"),
|
||||
GetString(latest, "message"),
|
||||
GetString(latest, "description"),
|
||||
GetString(root, "message_md"),
|
||||
GetString(root, "messageMarkdown"),
|
||||
GetString(root, "message"),
|
||||
GetString(root, "home_notes"));
|
||||
var rawMarkdown = FirstNonEmpty(
|
||||
GetString(latest, "release_notes_md"),
|
||||
GetString(latest, "releaseNotesMarkdown"),
|
||||
GetString(latest, "changelog_md"),
|
||||
GetString(latest, "latestNotesMarkdown"),
|
||||
GetString(latest, "latest_notes_md"),
|
||||
GetString(root, "release_notes_md"),
|
||||
GetString(root, "releaseNotesMarkdown"),
|
||||
GetString(root, "changelog_md"),
|
||||
GetString(root, "latestNotesMarkdown"),
|
||||
GetString(root, "latest_notes_md"));
|
||||
var rawText = FirstNonEmpty(
|
||||
GetString(latest, "release_notes"),
|
||||
GetString(latest, "releaseNotes"),
|
||||
GetString(latest, "changelog"),
|
||||
GetString(root, "release_notes"),
|
||||
GetString(root, "releaseNotes"),
|
||||
GetString(root, "changelog"));
|
||||
|
||||
var categories = ParseCategories(latest, root);
|
||||
var sections = ParseDictionarySections(latest, root, "update_notes", categories);
|
||||
if (sections.Count == 0 && !string.IsNullOrWhiteSpace(rawMarkdown))
|
||||
{
|
||||
sections = ParseMarkdownSections(rawMarkdown, categories);
|
||||
}
|
||||
|
||||
if (sections.Count == 0 && !string.IsNullOrWhiteSpace(rawText))
|
||||
{
|
||||
sections = [new UpdateNoticeSection("updates", "更新内容", "\uE8D4", SplitPlainText(rawText).ToArray())];
|
||||
}
|
||||
|
||||
if (sections.Count == 0 && !string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
sections = [new UpdateNoticeSection("summary", "公告摘要", "\uE789", SplitPlainText(message).ToArray())];
|
||||
}
|
||||
|
||||
return new UpdateNoticeDocument(
|
||||
version,
|
||||
build,
|
||||
channel,
|
||||
title,
|
||||
FirstParagraph(message, rawMarkdown, rawText),
|
||||
TryDate(FirstNonEmpty(
|
||||
GetString(latest, "published_at"),
|
||||
GetString(latest, "release_date"),
|
||||
GetString(root, "published_at"),
|
||||
GetString(root, "last_updated"))),
|
||||
GetBoolean(latest, "mandatory") || GetBoolean(latest, "force_update"),
|
||||
categories,
|
||||
sections,
|
||||
ParseHistory(latest, root),
|
||||
rawMarkdown,
|
||||
rawText);
|
||||
}
|
||||
|
||||
public static UpdateNoticeDocument FromText(
|
||||
string version,
|
||||
string build,
|
||||
string channel,
|
||||
string title,
|
||||
string message,
|
||||
string releaseNotes,
|
||||
string markdown,
|
||||
DateTimeOffset? publishedAt,
|
||||
bool mandatory)
|
||||
{
|
||||
var sections = !string.IsNullOrWhiteSpace(markdown)
|
||||
? ParseMarkdownSections(markdown, [])
|
||||
: [new UpdateNoticeSection("updates", "更新内容", "\uE8D4", SplitPlainText(releaseNotes).ToArray())];
|
||||
if (sections.Count == 0)
|
||||
{
|
||||
sections = [new UpdateNoticeSection("summary", "公告摘要", "\uE789", SplitPlainText(message).ToArray())];
|
||||
}
|
||||
|
||||
return new UpdateNoticeDocument(
|
||||
version,
|
||||
build,
|
||||
channel,
|
||||
title,
|
||||
FirstParagraph(message, markdown, releaseNotes),
|
||||
publishedAt,
|
||||
mandatory,
|
||||
[],
|
||||
sections,
|
||||
[],
|
||||
markdown,
|
||||
releaseNotes);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UpdateNoticeCategory> ParseCategories(params JsonElement[] roots)
|
||||
{
|
||||
foreach (var root in roots)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object ||
|
||||
!root.TryGetProperty("category_list", out var value) ||
|
||||
value.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var categories = value.EnumerateArray()
|
||||
.Where(item => item.ValueKind == JsonValueKind.Object)
|
||||
.Select(item => new UpdateNoticeCategory(
|
||||
FirstNonEmpty(GetString(item, "id"), Slug(GetString(item, "name"))),
|
||||
FirstNonEmpty(GetString(item, "name"), GetString(item, "id")),
|
||||
IconFor(FirstNonEmpty(GetString(item, "icon"), GetString(item, "id"), GetString(item, "name")))))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Name))
|
||||
.ToArray();
|
||||
if (categories.Length > 0)
|
||||
{
|
||||
return categories;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UpdateNoticeSection> ParseDictionarySections(
|
||||
JsonElement latest,
|
||||
JsonElement root,
|
||||
string fieldName,
|
||||
IReadOnlyList<UpdateNoticeCategory> categories)
|
||||
{
|
||||
var source = TryGetObject(latest, fieldName) ?? TryGetObject(root, fieldName);
|
||||
if (source is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return source.Value.EnumerateObject()
|
||||
.Select(property =>
|
||||
{
|
||||
var category = MatchCategory(property.Name, categories);
|
||||
return new UpdateNoticeSection(
|
||||
category?.Id ?? Slug(property.Name),
|
||||
property.Name,
|
||||
category?.Icon ?? IconFor(property.Name),
|
||||
SplitPlainText(ElementToText(property.Value), property.Name).ToArray());
|
||||
})
|
||||
.Where(section => section.Items.Count > 0)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UpdateNoticeSection> ParseMarkdownSections(
|
||||
string markdown,
|
||||
IReadOnlyList<UpdateNoticeCategory> categories)
|
||||
{
|
||||
var sections = new List<UpdateNoticeSection>();
|
||||
var title = "更新内容";
|
||||
var lines = new List<string>();
|
||||
|
||||
foreach (var raw in NormalizeLines(markdown))
|
||||
{
|
||||
var line = raw.TrimEnd();
|
||||
var heading = Regex.Match(line, @"^\s{0,3}#{1,3}\s+(?<title>.+)$");
|
||||
if (heading.Success)
|
||||
{
|
||||
AddSection();
|
||||
title = CleanInline(heading.Groups["title"].Value);
|
||||
lines.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.Add(line);
|
||||
}
|
||||
|
||||
AddSection();
|
||||
return sections;
|
||||
|
||||
void AddSection()
|
||||
{
|
||||
var items = ParseMarkdownItems(lines, title).ToArray();
|
||||
if (items.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var category = MatchCategory(title, categories);
|
||||
sections.Add(new UpdateNoticeSection(
|
||||
category?.Id ?? Slug(title),
|
||||
title,
|
||||
category?.Icon ?? IconFor(title),
|
||||
items));
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<UpdateNoticeItem> ParseMarkdownItems(IReadOnlyList<string> lines, string sectionTitle)
|
||||
{
|
||||
var buffer = new List<string>();
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
Flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
var list = Regex.Match(line.Trim(), @"^(\d+[\.)]|[-*+])\s+(?<text>.+)$");
|
||||
if (list.Success)
|
||||
{
|
||||
Flush();
|
||||
yield return ItemFromText(CleanInline(list.Groups["text"].Value), sectionTitle);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.TrimStart().StartsWith('|'))
|
||||
{
|
||||
buffer.Add(line.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var item in Flush())
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
|
||||
IEnumerable<UpdateNoticeItem> Flush()
|
||||
{
|
||||
if (buffer.Count == 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var text = CleanInline(string.Join(" ", buffer));
|
||||
buffer.Clear();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
foreach (var item in SplitPlainText(text, sectionTitle))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<UpdateNoticeSection> ParseHistory(JsonElement latest, JsonElement root)
|
||||
{
|
||||
var source = TryGetObject(latest, "last_update_notes") ?? TryGetObject(root, "last_update_notes");
|
||||
if (source is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var items = source.Value.EnumerateObject()
|
||||
.Select(property => ItemFromText(ElementToText(property.Value), property.Name))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Body) || !string.IsNullOrWhiteSpace(item.Title))
|
||||
.ToArray();
|
||||
return items.Length == 0 ? [] : [new UpdateNoticeSection("history", "历史版本", "\uE81C", items)];
|
||||
}
|
||||
|
||||
private static IEnumerable<UpdateNoticeItem> SplitPlainText(string text, string fallbackTitle = "")
|
||||
{
|
||||
foreach (var part in Regex.Split(text ?? string.Empty, @"(?<=[。!?;;.!?])\s+|[\r\n]+|(?<=;)|(?<=;)"))
|
||||
{
|
||||
var clean = CleanInline(part).Trim(' ', '-', '*', '•');
|
||||
if (string.IsNullOrWhiteSpace(clean))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
yield return ItemFromText(clean, fallbackTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private static UpdateNoticeItem ItemFromText(string text, string fallbackTitle)
|
||||
{
|
||||
var clean = CleanInline(text);
|
||||
var parts = clean.Split([':', ':'], 2, StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 2 && parts[0].Length is > 0 and <= 28)
|
||||
{
|
||||
return new UpdateNoticeItem(parts[0], parts[1], KindFor(parts[0]), TagFor(parts[0]));
|
||||
}
|
||||
|
||||
return new UpdateNoticeItem(string.IsNullOrWhiteSpace(fallbackTitle) ? "更新项" : fallbackTitle, clean, KindFor(clean), TagFor(clean));
|
||||
}
|
||||
|
||||
private static UpdateNoticeCategory? MatchCategory(string title, IReadOnlyList<UpdateNoticeCategory> categories)
|
||||
{
|
||||
var slug = Slug(title);
|
||||
return categories.FirstOrDefault(category =>
|
||||
string.Equals(category.Id, slug, StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains(category.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string FirstParagraph(params string[] values)
|
||||
=> CleanInline(values
|
||||
.SelectMany(NormalizeLines)
|
||||
.Select(line => line.Trim())
|
||||
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line) && !line.StartsWith('#') && !Regex.IsMatch(line, @"^(\d+[\.)]|[-*+])\s+")) ?? string.Empty);
|
||||
|
||||
private static IEnumerable<string> NormalizeLines(string value)
|
||||
=> (value ?? string.Empty).Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n');
|
||||
|
||||
private static string CleanInline(string value)
|
||||
=> Regex.Replace(value ?? string.Empty, @"\*\*(?<text>.+?)\*\*|`(?<code>.+?)`|\[(?<link>[^\]]+)\]\([^)]+\)", match =>
|
||||
{
|
||||
if (match.Groups["text"].Success)
|
||||
{
|
||||
return match.Groups["text"].Value;
|
||||
}
|
||||
|
||||
if (match.Groups["code"].Success)
|
||||
{
|
||||
return match.Groups["code"].Value;
|
||||
}
|
||||
|
||||
return match.Groups["link"].Success ? match.Groups["link"].Value : match.Value;
|
||||
}).Trim();
|
||||
|
||||
private static string KindFor(string text)
|
||||
{
|
||||
if (Regex.IsMatch(text, "修复|解决|失败|错误|异常|fix|bug", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return "fix";
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(text, "新增|支持|增加|add|new", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return "new";
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(text, "优化|调整|体验|重构|改善|improve|optimize", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return "improve";
|
||||
}
|
||||
|
||||
return "change";
|
||||
}
|
||||
|
||||
private static string TagFor(string text)
|
||||
=> KindFor(text) switch
|
||||
{
|
||||
"fix" => "修复",
|
||||
"new" => "新增",
|
||||
"improve" => "优化",
|
||||
_ => "调整"
|
||||
};
|
||||
|
||||
private static string IconFor(string value)
|
||||
{
|
||||
if (Regex.IsMatch(value, "修复|稳定|安全|shield|fix|bug", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return "\uE83D";
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(value, "导航|交互|route|navigation", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return "\uE8AB";
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(value, "工具|能力|新增|new|tool", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return "\uE90F";
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(value, "历史|last|history", RegexOptions.IgnoreCase))
|
||||
{
|
||||
return "\uE81C";
|
||||
}
|
||||
|
||||
return "\uE8D4";
|
||||
}
|
||||
|
||||
private static string Slug(string value)
|
||||
{
|
||||
var clean = Regex.Replace(value ?? string.Empty, @"[^\p{L}\p{N}]+", "-").Trim('-');
|
||||
return string.IsNullOrWhiteSpace(clean) ? "updates" : clean.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static JsonElement? TryGetObject(JsonElement root, string name)
|
||||
=> root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty(name, out var value) &&
|
||||
value.ValueKind == JsonValueKind.Object
|
||||
? value
|
||||
: null;
|
||||
|
||||
private static string ElementToText(JsonElement element)
|
||||
=> element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString() ?? string.Empty,
|
||||
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.ToString(),
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
private static string GetString(JsonElement root, string name)
|
||||
=> root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty(name, out var value)
|
||||
? value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString() ?? string.Empty,
|
||||
JsonValueKind.Number => value.GetRawText(),
|
||||
JsonValueKind.True => "true",
|
||||
JsonValueKind.False => "false",
|
||||
_ => string.Empty
|
||||
}
|
||||
: string.Empty;
|
||||
|
||||
private static bool GetBoolean(JsonElement root, string name)
|
||||
=> root.ValueKind == JsonValueKind.Object &&
|
||||
root.TryGetProperty(name, out var value) &&
|
||||
value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String => bool.TryParse(value.GetString(), out var parsed) && parsed,
|
||||
_ => false
|
||||
};
|
||||
|
||||
private static DateTimeOffset? TryDate(string value)
|
||||
=> DateTimeOffset.TryParse(value, out var date) ? date : null;
|
||||
|
||||
private static string FirstNonEmpty(params string[] values)
|
||||
=> values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using YMhut.Box.Core.Updates;
|
||||
|
||||
namespace YMhut.Box.Tests;
|
||||
|
||||
[TestClass]
|
||||
public sealed class UpdateNoticeDocumentTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ReleaseNotesMarkdownBuildsReadableSections()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"app_version": "2.0.7",
|
||||
"build": "10",
|
||||
"channel": "stable",
|
||||
"title": "YMhut Box 2.0.7.10",
|
||||
"release_notes_md": "## 新增能力\n\n- 新增结构化更新日志。\n- 支持分类和历史版本。\n\n## 修复优化\n\n- 修复长文本挤在一起的问题。"
|
||||
}
|
||||
""");
|
||||
|
||||
var document = UpdateNoticeDocumentBuilder.FromJson(json.RootElement, json.RootElement);
|
||||
|
||||
Assert.AreEqual("2.0.7", document.Version);
|
||||
Assert.HasCount(2, document.Sections);
|
||||
Assert.AreEqual("新增能力", document.Sections[0].Title);
|
||||
Assert.HasCount(2, document.Sections[0].Items);
|
||||
Assert.AreEqual("修复优化", document.Sections[1].Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateNotesAndCategoriesProduceStructuredCards()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"app_version": "2.0.7",
|
||||
"category_list": [
|
||||
{ "id": "shell", "name": "壳层体验", "icon": "monitor" },
|
||||
{ "id": "stability", "name": "稳定性", "icon": "shield" }
|
||||
],
|
||||
"update_notes": {
|
||||
"壳层体验": "更新日志改为结构化卡片。",
|
||||
"稳定性": "修复公告弹窗拥挤。"
|
||||
},
|
||||
"last_update_notes": {
|
||||
"v2.0.6": "上一版优化工具结果展示。"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var document = UpdateNoticeDocumentBuilder.FromJson(json.RootElement, json.RootElement);
|
||||
|
||||
Assert.HasCount(2, document.Categories);
|
||||
Assert.HasCount(2, document.Sections);
|
||||
Assert.HasCount(1, document.History);
|
||||
Assert.AreEqual("壳层体验", document.Sections[0].Title);
|
||||
Assert.AreEqual("稳定性", document.Sections[1].Title);
|
||||
Assert.AreEqual("历史版本", document.History[0].Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PlainReleaseNotesSplitIntoMultipleItems()
|
||||
{
|
||||
using var json = JsonDocument.Parse("""
|
||||
{
|
||||
"app_version": "2.0.7",
|
||||
"release_notes": "修复更新日志过于拥挤;新增卡片式展示;优化弹窗滚动。"
|
||||
}
|
||||
""");
|
||||
|
||||
var document = UpdateNoticeDocumentBuilder.FromJson(json.RootElement, json.RootElement);
|
||||
|
||||
Assert.HasCount(1, document.Sections);
|
||||
Assert.IsGreaterThanOrEqualTo(document.Sections[0].Items.Count, 3);
|
||||
Assert.IsTrue(document.Sections[0].Items.Any(item => item.Kind == "fix"));
|
||||
Assert.IsTrue(document.Sections[0].Items.Any(item => item.Kind == "new"));
|
||||
}
|
||||
}
|
||||
@@ -138,8 +138,8 @@ internal sealed class AppShell : Grid
|
||||
{
|
||||
IsBackButtonVisible = NavigationViewBackButtonVisible.Collapsed,
|
||||
IsSettingsVisible = false,
|
||||
PaneDisplayMode = NavigationViewPaneDisplayMode.Left,
|
||||
IsPaneOpen = true,
|
||||
PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact,
|
||||
IsPaneOpen = false,
|
||||
CompactPaneLength = 56,
|
||||
OpenPaneLength = 260,
|
||||
SelectionFollowsFocus = NavigationViewSelectionFollowsFocus.Disabled,
|
||||
|
||||
@@ -535,8 +535,8 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
ToolTipService.SetToolTip(ThemeToggleButton, "切换主题");
|
||||
ConfigureTopButtonFeedback();
|
||||
ApplyLanguage();
|
||||
RootNavigation.PaneDisplayMode = NavigationViewPaneDisplayMode.Left;
|
||||
RootNavigation.IsPaneOpen = true;
|
||||
RootNavigation.PaneDisplayMode = NavigationViewPaneDisplayMode.LeftCompact;
|
||||
RootNavigation.IsPaneOpen = false;
|
||||
RootNavigation.SelectedItem = HomeNavItem;
|
||||
RefreshPluginNavigation();
|
||||
}
|
||||
@@ -1961,7 +1961,7 @@ public sealed partial class MainWindow : Window, IShellNavigationHost
|
||||
: phone
|
||||
? NavigationViewPaneDisplayMode.LeftMinimal
|
||||
: NavigationViewPaneDisplayMode.LeftCompact;
|
||||
RootNavigation.IsPaneOpen = wide;
|
||||
RootNavigation.IsPaneOpen = false;
|
||||
RootNavigation.CompactPaneLength = 56;
|
||||
RootNavigation.OpenPaneLength = wide ? 260 : 228;
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ public sealed class AppInstallerUpdateService(
|
||||
return null;
|
||||
}
|
||||
|
||||
var noticeDocument = UpdateNoticeDocumentBuilder.FromJson(root, latest);
|
||||
var build = FirstNonEmpty(GetString(latest, "build"), GetString(latest, "build_number"), GetString(root, "build"));
|
||||
var download = FirstNonEmpty(
|
||||
GetString(installer, "url"),
|
||||
@@ -209,7 +210,8 @@ public sealed class AppInstallerUpdateService(
|
||||
GetString(package, "updateTime"),
|
||||
GetString(package, "updateDate"))),
|
||||
messageMarkdown,
|
||||
releaseNotesMarkdown);
|
||||
releaseNotesMarkdown,
|
||||
noticeDocument);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -27,7 +27,8 @@ public sealed record RemoteUpdateInfo(
|
||||
long SizeBytes,
|
||||
DateTimeOffset? PublishedAt,
|
||||
string MessageMarkdown = "",
|
||||
string ReleaseNotesMarkdown = "")
|
||||
string ReleaseNotesMarkdown = "",
|
||||
UpdateNoticeDocument? NoticeDocument = null)
|
||||
{
|
||||
public string EffectiveVersion => UpdateVersionComparer.NormalizeVersion(Version, Build);
|
||||
public string NormalizedVersion => EffectiveVersion;
|
||||
@@ -35,6 +36,16 @@ public sealed record RemoteUpdateInfo(
|
||||
public bool HasMarkdownNotes => !string.IsNullOrWhiteSpace(MessageMarkdown) || !string.IsNullOrWhiteSpace(ReleaseNotesMarkdown);
|
||||
public string DisplayMessage => string.IsNullOrWhiteSpace(MessageMarkdown) ? Message : MessageMarkdown;
|
||||
public string DisplayReleaseNotes => string.IsNullOrWhiteSpace(ReleaseNotesMarkdown) ? ReleaseNotes : ReleaseNotesMarkdown;
|
||||
public UpdateNoticeDocument Notice => NoticeDocument ?? UpdateNoticeDocumentBuilder.FromText(
|
||||
Version,
|
||||
Build,
|
||||
Channel,
|
||||
Title,
|
||||
DisplayMessage,
|
||||
ReleaseNotes,
|
||||
ReleaseNotesMarkdown,
|
||||
PublishedAt,
|
||||
Mandatory);
|
||||
|
||||
public int CompareToCurrent(string currentVersion) => UpdateVersionComparer.Compare(Version, Build, currentVersion);
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using Microsoft.UI.Text;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using YMhut.Box.Core.Updates;
|
||||
|
||||
namespace YMhut.Box.WinUI;
|
||||
|
||||
internal enum UpdateNoticeRenderMode
|
||||
{
|
||||
CompactDialog,
|
||||
FullDialog,
|
||||
UpdatePrompt
|
||||
}
|
||||
|
||||
internal static class UpdateNoticeRenderer
|
||||
{
|
||||
public static UIElement Render(UpdateNoticeDocument document, UpdateNoticeRenderMode mode, string? currentVersion = null)
|
||||
{
|
||||
var root = new StackPanel { Spacing = 14 };
|
||||
root.Children.Add(BuildHero(document, mode, currentVersion));
|
||||
|
||||
var body = new Grid { ColumnSpacing = 14 };
|
||||
body.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(mode == UpdateNoticeRenderMode.CompactDialog ? 0 : 172) });
|
||||
body.ColumnDefinitions.Add(new ColumnDefinition());
|
||||
|
||||
if (mode != UpdateNoticeRenderMode.CompactDialog)
|
||||
{
|
||||
body.Children.Add(BuildCategoryRail(document));
|
||||
}
|
||||
|
||||
var sections = new StackPanel { Spacing = 12 };
|
||||
foreach (var section in document.Sections)
|
||||
{
|
||||
sections.Children.Add(BuildSection(section));
|
||||
}
|
||||
|
||||
foreach (var section in document.History)
|
||||
{
|
||||
sections.Children.Add(BuildSection(section, compact: true));
|
||||
}
|
||||
|
||||
if (sections.Children.Count == 0)
|
||||
{
|
||||
sections.Children.Add(ModernUi.Card(
|
||||
ModernUi.Text(AppLocalizer.T("暂无更新日志。", "No update notes."), 14, foreground: ModernUi.TextSecondary),
|
||||
new Thickness(14),
|
||||
radius: 8,
|
||||
background: ModernUi.SurfaceAlt));
|
||||
}
|
||||
|
||||
Grid.SetColumn(sections, 1);
|
||||
body.Children.Add(sections);
|
||||
root.Children.Add(body);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(document.RawMarkdown) && mode == UpdateNoticeRenderMode.FullDialog)
|
||||
{
|
||||
var expander = new Expander
|
||||
{
|
||||
Header = AppLocalizer.T("原始 Markdown", "Raw Markdown"),
|
||||
Content = MarkdownRenderHelper.Render(document.RawMarkdown)
|
||||
};
|
||||
root.Children.Add(expander);
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static UIElement BuildHero(UpdateNoticeDocument document, UpdateNoticeRenderMode mode, string? currentVersion)
|
||||
{
|
||||
var grid = new Grid { ColumnSpacing = 16, RowSpacing = 10 };
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||||
|
||||
grid.Children.Add(ModernUi.IconTile("\uE8D4", 46, ModernUi.AccentSoft, ModernUi.Accent, 20));
|
||||
var title = string.IsNullOrWhiteSpace(document.Title)
|
||||
? AppLocalizer.T("更新日志", "Update notes")
|
||||
: AppLocalizer.SanitizeSensitiveText(document.Title, 120);
|
||||
var summary = string.IsNullOrWhiteSpace(document.Summary)
|
||||
? AppLocalizer.T("本次更新内容已经按分类整理。", "This update is organized by category.")
|
||||
: AppLocalizer.SanitizeSensitiveText(document.Summary, mode == UpdateNoticeRenderMode.UpdatePrompt ? 180 : 260);
|
||||
|
||||
var text = new StackPanel
|
||||
{
|
||||
Spacing = 7,
|
||||
Children =
|
||||
{
|
||||
ModernUi.Text(title, mode == UpdateNoticeRenderMode.UpdatePrompt ? 19 : 20, FontWeights.SemiBold, maxLines: 2),
|
||||
ModernUi.Text(summary, 13.5, foreground: ModernUi.TextSecondary, maxLines: mode == UpdateNoticeRenderMode.UpdatePrompt ? 3 : 4),
|
||||
ModernUi.BadgeRow(BuildMetaBadges(document, currentVersion), itemWidth: 132, itemHeight: 28, maxHeight: 68)
|
||||
}
|
||||
};
|
||||
Grid.SetColumn(text, 1);
|
||||
grid.Children.Add(text);
|
||||
|
||||
return ModernUi.Card(grid, new Thickness(16), radius: 8, background: ModernUi.Surface);
|
||||
}
|
||||
|
||||
private static IEnumerable<UIElement> BuildMetaBadges(UpdateNoticeDocument document, string? currentVersion)
|
||||
{
|
||||
yield return ModernUi.SmallBadge(
|
||||
AppLocalizer.T($"版本 {DisplayVersion(document)}", $"Version {DisplayVersion(document)}"),
|
||||
ModernUi.Accent,
|
||||
ModernUi.AccentSoft);
|
||||
if (!string.IsNullOrWhiteSpace(currentVersion))
|
||||
{
|
||||
yield return ModernUi.SmallBadge(
|
||||
AppLocalizer.T($"当前 {currentVersion}", $"Current {currentVersion}"),
|
||||
ModernUi.TextSecondary,
|
||||
ModernUi.SurfaceAlt);
|
||||
}
|
||||
|
||||
yield return ModernUi.SmallBadge(
|
||||
string.IsNullOrWhiteSpace(document.Channel) ? "stable" : document.Channel,
|
||||
ModernUi.TextSecondary,
|
||||
ModernUi.SurfaceAlt);
|
||||
yield return ModernUi.SmallBadge(
|
||||
document.PublishedAt?.ToLocalTime().ToString("yyyy-MM-dd") ?? AppLocalizer.T("日期未知", "Date unknown"),
|
||||
ModernUi.TextSecondary,
|
||||
ModernUi.SurfaceAlt);
|
||||
if (document.Mandatory)
|
||||
{
|
||||
yield return ModernUi.SmallBadge(AppLocalizer.T("强制更新", "Mandatory"), ModernUi.Danger, ModernUi.SurfaceAlt);
|
||||
}
|
||||
}
|
||||
|
||||
private static UIElement BuildCategoryRail(UpdateNoticeDocument document)
|
||||
{
|
||||
var panel = new StackPanel { Spacing = 8 };
|
||||
panel.Children.Add(ModernUi.Text(AppLocalizer.T("分区", "Sections"), 12, FontWeights.SemiBold, ModernUi.TextSecondary, maxLines: 1));
|
||||
foreach (var section in document.Sections.Concat(document.History).Take(10))
|
||||
{
|
||||
var row = new Grid { ColumnSpacing = 8 };
|
||||
row.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
row.ColumnDefinitions.Add(new ColumnDefinition());
|
||||
row.Children.Add(ModernUi.IconTile(string.IsNullOrWhiteSpace(section.Icon) ? "\uE8D4" : section.Icon, 28, ModernUi.SurfaceAlt, ModernUi.Accent, 12));
|
||||
var label = ModernUi.Text(section.Title, 12.5, FontWeights.SemiBold, ModernUi.TextPrimary, maxLines: 2);
|
||||
Grid.SetColumn(label, 1);
|
||||
row.Children.Add(label);
|
||||
panel.Children.Add(ModernUi.Card(row, new Thickness(8), radius: 8, background: ModernUi.SurfaceAlt));
|
||||
}
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
private static UIElement BuildSection(UpdateNoticeSection section, bool compact = false)
|
||||
{
|
||||
var panel = new StackPanel { Spacing = 10 };
|
||||
var header = new Grid { ColumnSpacing = 10 };
|
||||
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
header.ColumnDefinitions.Add(new ColumnDefinition());
|
||||
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
header.Children.Add(ModernUi.IconTile(string.IsNullOrWhiteSpace(section.Icon) ? "\uE8D4" : section.Icon, 34, ModernUi.AccentSoft, ModernUi.Accent, 15));
|
||||
var title = ModernUi.Text(section.Title, 16, FontWeights.SemiBold, maxLines: 2);
|
||||
Grid.SetColumn(title, 1);
|
||||
header.Children.Add(title);
|
||||
var count = ModernUi.SmallBadge(AppLocalizer.T($"{section.Items.Count} 项", $"{section.Items.Count} items"), ModernUi.TextSecondary, ModernUi.SurfaceAlt);
|
||||
Grid.SetColumn(count, 2);
|
||||
header.Children.Add(count);
|
||||
panel.Children.Add(header);
|
||||
|
||||
foreach (var item in section.Items.Take(compact ? 8 : 60))
|
||||
{
|
||||
panel.Children.Add(BuildItem(item, compact));
|
||||
}
|
||||
|
||||
return ModernUi.Card(panel, new Thickness(14), radius: 8, background: ModernUi.Surface);
|
||||
}
|
||||
|
||||
private static UIElement BuildItem(UpdateNoticeItem item, bool compact)
|
||||
{
|
||||
var grid = new Grid { ColumnSpacing = 10 };
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
grid.ColumnDefinitions.Add(new ColumnDefinition());
|
||||
|
||||
var badge = ModernUi.SmallBadge(string.IsNullOrWhiteSpace(item.Tag) ? AppLocalizer.T("更新", "Change") : item.Tag, BrushFor(item.Kind), ModernUi.SurfaceAlt);
|
||||
Grid.SetColumn(badge, 0);
|
||||
grid.Children.Add(badge);
|
||||
|
||||
var text = new StackPanel { Spacing = 2 };
|
||||
if (!string.IsNullOrWhiteSpace(item.Title) &&
|
||||
!string.Equals(item.Title, item.Body, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text.Children.Add(ModernUi.Text(AppLocalizer.SanitizeSensitiveText(item.Title, 80), 13.5, FontWeights.SemiBold, maxLines: 2));
|
||||
}
|
||||
|
||||
text.Children.Add(ModernUi.Text(AppLocalizer.SanitizeSensitiveText(item.Body, compact ? 160 : 360), 13, foreground: ModernUi.TextSecondary, maxLines: compact ? 3 : 5));
|
||||
Grid.SetColumn(text, 1);
|
||||
grid.Children.Add(text);
|
||||
AutomationProperties.SetName(grid, $"{item.Tag} {item.Title} {item.Body}");
|
||||
return ModernUi.Card(grid, new Thickness(10), radius: 8, background: ModernUi.SurfaceAlt);
|
||||
}
|
||||
|
||||
private static Brush BrushFor(string kind)
|
||||
=> kind switch
|
||||
{
|
||||
"fix" => ModernUi.Danger,
|
||||
"new" => ModernUi.Success,
|
||||
"improve" => ModernUi.Accent,
|
||||
_ => ModernUi.TextSecondary
|
||||
};
|
||||
|
||||
private static string DisplayVersion(UpdateNoticeDocument document)
|
||||
=> string.IsNullOrWhiteSpace(document.Build)
|
||||
? document.Version
|
||||
: $"{document.Version}.{document.Build}";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.UI.Text;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
@@ -783,26 +783,10 @@ public sealed class AboutPage : Page
|
||||
|
||||
private async Task ShowUpdateDialogAsync(RemoteUpdateInfo info, string current)
|
||||
{
|
||||
var content = new StackPanel
|
||||
{
|
||||
Spacing = 10,
|
||||
Children =
|
||||
{
|
||||
ModernUi.Text(string.IsNullOrWhiteSpace(info.Title) ? AppLocalizer.T("发现新版本", "New version available") : AppLocalizer.SanitizeSensitiveText(info.Title, 120), 18, FontWeights.SemiBold),
|
||||
ModernUi.Text(string.IsNullOrWhiteSpace(info.DisplayMessage) ? AppLocalizer.T("远程发布信息未提供摘要。", "No release summary was provided.") : AppLocalizer.SanitizeSensitiveText(info.DisplayMessage, 300), 14, foreground: ModernUi.TextSecondary),
|
||||
BuildUpdateLine(AppLocalizer.T("最新版本", "Latest"), info.DisplayVersion),
|
||||
BuildUpdateLine(AppLocalizer.T("当前版本", "Current"), current),
|
||||
BuildUpdateLine(AppLocalizer.T("发布通道", "Channel"), string.IsNullOrWhiteSpace(info.Channel) ? "-" : info.Channel),
|
||||
BuildUpdateLine(AppLocalizer.T("发布时间", "Published"), info.PublishedAt?.ToLocalTime().ToString("yyyy-MM-dd HH:mm") ?? "-"),
|
||||
BuildUpdateLine(AppLocalizer.T("强制更新", "Mandatory"), info.Mandatory ? AppLocalizer.T("是", "Yes") : AppLocalizer.T("否", "No")),
|
||||
MarkdownRenderHelper.Render(string.IsNullOrWhiteSpace(info.DisplayReleaseNotes) ? AppLocalizer.T("暂无发布说明。", "No release notes.") : info.DisplayReleaseNotes)
|
||||
}
|
||||
};
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = AppLocalizer.T("软件更新", "Software update"),
|
||||
Content = content,
|
||||
Content = ModernUi.GutterScroll(UpdateNoticeRenderer.Render(info.Notice, UpdateNoticeRenderMode.UpdatePrompt, current), 620),
|
||||
PrimaryButtonText = AppLocalizer.T("立即更新", "Update now"),
|
||||
SecondaryButtonText = AppLocalizer.T("稍后", "Later"),
|
||||
CloseButtonText = AppLocalizer.T("取消", "Cancel"),
|
||||
@@ -824,7 +808,6 @@ public sealed class AboutPage : Page
|
||||
SetCheckingState(false);
|
||||
await ShowDownloadDialogAsync(info);
|
||||
}
|
||||
|
||||
private async Task ShowDownloadDialogAsync(RemoteUpdateInfo info)
|
||||
{
|
||||
await StartDownloadUpdateAsync(info);
|
||||
@@ -963,3 +946,4 @@ public sealed class AboutPage : Page
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -295,16 +295,15 @@ public sealed class HomePage : Page
|
||||
? AppLocalizer.T("完整公告", "Full announcement")
|
||||
: VersionAnnouncementTitle(info, relation);
|
||||
var content = info is null
|
||||
? AppLocalizer.T("欢迎使用 YMhut Box。新版 WinUI 首页已对齐旧版的公告、搜索和工具工作台体验。", "Welcome to YMhut Box. The WinUI home page now aligns with the classic announcement, search, and dashboard experience.")
|
||||
: CombineAnnouncement(info, current, relation);
|
||||
var meta = info is null
|
||||
? AppLocalizer.T("本地公告", "Local announcement")
|
||||
: VersionAnnouncementMeta(info, current, relation, includeTime: true);
|
||||
? MarkdownRenderHelper.Render(
|
||||
AppLocalizer.T("欢迎使用 YMhut Box。新版 WinUI 首页已对齐旧版的公告、搜索和工具工作台体验。", "Welcome to YMhut Box. The WinUI home page now aligns with the classic announcement, search, and dashboard experience."),
|
||||
AppLocalizer.T("本地公告", "Local announcement"))
|
||||
: UpdateNoticeRenderer.Render(info.Notice, UpdateNoticeRenderMode.FullDialog, current);
|
||||
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = title,
|
||||
Content = ModernUi.GutterScroll(MarkdownRenderHelper.Render(content, meta), 520),
|
||||
Content = ModernUi.GutterScroll(content, 620),
|
||||
CloseButtonText = AppLocalizer.T("关闭", "Close"),
|
||||
XamlRoot = XamlRoot
|
||||
};
|
||||
@@ -314,27 +313,26 @@ public sealed class HomePage : Page
|
||||
private async Task ShowUpdateNotesDialogAsync()
|
||||
{
|
||||
await EnsureAnnouncementLoadedAsync();
|
||||
var notes = _announcementInfo is null || string.IsNullOrWhiteSpace(_announcementInfo.DisplayReleaseNotes)
|
||||
? AppLocalizer.T("暂无远程更新日志。", "No remote update notes are available.")
|
||||
: _announcementInfo.DisplayReleaseNotes;
|
||||
var current = _versionService.GetCurrent().Version;
|
||||
var document = _announcementInfo?.Notice ?? UpdateNoticeDocumentBuilder.FromText(
|
||||
current,
|
||||
string.Empty,
|
||||
"local",
|
||||
AppLocalizer.T("更新日志", "Update notes"),
|
||||
AppLocalizer.T("暂无远程更新日志。", "No remote update notes are available."),
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
null,
|
||||
false);
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = AppLocalizer.T("更新日志", "Update notes"),
|
||||
Content = ModernUi.GutterScroll(
|
||||
MarkdownRenderHelper.Render(
|
||||
notes,
|
||||
_announcementInfo is null ? null : VersionAnnouncementMeta(
|
||||
_announcementInfo,
|
||||
_versionService.GetCurrent().Version,
|
||||
_announcementInfo.CompareToCurrent(_versionService.GetCurrent().Version),
|
||||
includeTime: false)),
|
||||
560),
|
||||
Content = ModernUi.GutterScroll(UpdateNoticeRenderer.Render(document, UpdateNoticeRenderMode.CompactDialog, current), 620),
|
||||
CloseButtonText = AppLocalizer.T("关闭", "Close"),
|
||||
XamlRoot = XamlRoot
|
||||
};
|
||||
await dialog.ShowAsync();
|
||||
}
|
||||
|
||||
private async Task EnsureAnnouncementLoadedAsync()
|
||||
{
|
||||
if (!_announcementLoaded)
|
||||
@@ -1204,3 +1202,4 @@ public sealed class HomePage : Page
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user