This commit is contained in:
QWQLwToo
2026-07-12 09:12:12 +08:00
parent b22914b5de
commit ef25f8875c
43 changed files with 2797 additions and 709 deletions
@@ -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)
+93
View File
@@ -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",
+126 -29
View File
@@ -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">
+93
View File
@@ -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>
+126 -10
View File
@@ -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; }
}
+93
View File
@@ -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");
+9 -9
View File
@@ -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"
}