fix: visual fixes — dark theme default, sidebar, dashboard cards, timeline dots, header counts
- Set data-theme=dark as default in index.html, useTheme defaults to 'dark' (issues 1, 10) - Rename "Cron" to "Cron 管理" in sidebar (issue 2) - Set card grid gap to 14px per design spec (issue 3) - Show D-grade profile names below health bar chart (issue 4) - Remove R/F/H/OK text from timeline, use colored dots with hover tooltip (issue 5) - Wire live online/running/offline counts via usePolling in header, add offline badge (issue 9) - Fix search placeholder to "搜索 Profile / 任务 / Cron / 事件..." (issue 9) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTheme } from '../../hooks/useTheme'
|
||||
import { usePolling } from '../../hooks/usePolling'
|
||||
import { getDashboardSummary } from '../../api/dashboard'
|
||||
import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
import MobileNav from './MobileNav'
|
||||
@@ -27,6 +29,7 @@ export interface AppLayoutProps {
|
||||
breadcrumb?: string
|
||||
onlineCount?: number
|
||||
runningCount?: number
|
||||
offlineCount?: number
|
||||
onSearch?: (query: string) => any
|
||||
onSearchNavigate?: (value: string) => void
|
||||
}
|
||||
@@ -36,6 +39,7 @@ export default function AppLayout({
|
||||
breadcrumb,
|
||||
onlineCount = 0,
|
||||
runningCount = 0,
|
||||
offlineCount = 0,
|
||||
onSearch,
|
||||
onSearchNavigate,
|
||||
}: AppLayoutProps) {
|
||||
@@ -52,6 +56,23 @@ export default function AppLayout({
|
||||
|
||||
const sidebarWidth = isTablet ? 180 : 220
|
||||
|
||||
/* ---- live header counts ---- */
|
||||
const [liveOnline, setLiveOnline] = useState(0)
|
||||
const [liveRunning, setLiveRunning] = useState(0)
|
||||
const [liveOffline, setLiveOffline] = useState(0)
|
||||
usePolling(async () => {
|
||||
try {
|
||||
const res = await getDashboardSummary()
|
||||
setLiveOnline(res.data.online_count)
|
||||
setLiveRunning(res.data.running_tasks)
|
||||
setLiveOffline(res.data.offline_count)
|
||||
} catch { /* ignore polling errors */ }
|
||||
}, 30_000)
|
||||
|
||||
const hOnline = onlineCount || liveOnline
|
||||
const hRunning = runningCount || liveRunning
|
||||
const hOffline = offlineCount || liveOffline
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col" style={{ background: 'var(--bg-root)' }}>
|
||||
@@ -120,8 +141,9 @@ export default function AppLayout({
|
||||
<div style={{ gridArea: 'header' }}>
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
onlineCount={onlineCount}
|
||||
runningCount={runningCount}
|
||||
onlineCount={hOnline}
|
||||
runningCount={hRunning}
|
||||
offlineCount={hOffline}
|
||||
onSearch={onSearch}
|
||||
onSearchNavigate={onSearchNavigate}
|
||||
theme={theme}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface HeaderProps {
|
||||
breadcrumb?: string
|
||||
onlineCount?: number
|
||||
runningCount?: number
|
||||
offlineCount?: number
|
||||
onSearch?: (query: string) => any
|
||||
onSearchNavigate?: (value: string) => void
|
||||
theme: string
|
||||
@@ -17,6 +18,7 @@ export default function Header({
|
||||
breadcrumb,
|
||||
onlineCount = 0,
|
||||
runningCount = 0,
|
||||
offlineCount = 0,
|
||||
onSearch,
|
||||
onSearchNavigate,
|
||||
theme,
|
||||
@@ -56,12 +58,13 @@ export default function Header({
|
||||
|
||||
{/* Search */}
|
||||
{onSearch && (
|
||||
<SearchBox onSearch={onSearch} onItemClick={onSearchNavigate} placeholder="搜索功能、任务、日志..." />
|
||||
<SearchBox onSearch={onSearch} onItemClick={onSearchNavigate} placeholder="搜索 Profile / 任务 / Cron / 事件..." />
|
||||
)}
|
||||
|
||||
{/* Status badges */}
|
||||
<Badge variant="success">在线 {onlineCount}</Badge>
|
||||
<Badge variant="honey">运行中 {runningCount}</Badge>
|
||||
<Badge variant="danger">离线 {offlineCount}</Badge>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
|
||||
@@ -30,7 +30,7 @@ const sections: NavSection[] = [
|
||||
title: '任务编排',
|
||||
items: [
|
||||
{ label: '看板追踪', icon: '📋', path: '/kanban' },
|
||||
{ label: 'Cron', icon: '⏰', path: '/cron', requiredRole: 'operator' },
|
||||
{ label: 'Cron 管理', icon: '⏰', path: '/cron', requiredRole: 'operator' },
|
||||
{ label: '事件管理', icon: '🔔', path: '/events' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export interface TimelineEvent {
|
||||
time: string
|
||||
type: string
|
||||
label: ReactNode
|
||||
tooltip?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
@@ -35,10 +33,10 @@ export default function Timeline({ events, className = "" }: TimelineProps) {
|
||||
>
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-[11px] whitespace-nowrap rounded-[var(--radius-sm)] opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10"
|
||||
style={{ backgroundColor: "var(--bg-elevated)", color: "var(--text)" }}>
|
||||
{evt.time}
|
||||
<div>{evt.time}</div>
|
||||
{evt.tooltip && <div style={{ color: "var(--text-dim)" }}>{evt.tooltip}</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--text-dim)] mt-1">{evt.label}</div>
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
type Theme = 'dark' | 'light'
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
return (localStorage.getItem('hive-theme') as Theme) || 'system'
|
||||
return (localStorage.getItem('hive-theme') as Theme) || 'dark'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
|
||||
} else {
|
||||
root.setAttribute('data-theme', theme)
|
||||
}
|
||||
root.setAttribute('data-theme', theme)
|
||||
localStorage.setItem('hive-theme', theme)
|
||||
}, [theme])
|
||||
|
||||
|
||||
@@ -62,37 +62,49 @@ function HealthBarChart({
|
||||
const maxVal = Math.max(dist.A, dist.B, dist.C, dist.D, 1)
|
||||
|
||||
return (
|
||||
<div className="space-y-3 mt-2">
|
||||
{GRADE_META.map(({ key, color }) => {
|
||||
const val = dist[key as keyof typeof dist] as number
|
||||
const pct = (val / maxVal) * 100
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 cursor-pointer group"
|
||||
onClick={() => onBarClick(key)}
|
||||
>
|
||||
<span
|
||||
className="w-5 text-center text-[13px] font-bold shrink-0"
|
||||
style={{ color }}
|
||||
<div>
|
||||
<div className="space-y-3 mt-2">
|
||||
{GRADE_META.map(({ key, color }) => {
|
||||
const val = dist[key as keyof typeof dist] as number
|
||||
const pct = (val / maxVal) * 100
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 cursor-pointer group"
|
||||
onClick={() => onBarClick(key)}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
<div className="flex-1 h-6 rounded-[var(--radius-sm)] bg-[var(--bg-hover)] overflow-hidden relative">
|
||||
<div
|
||||
className="h-full rounded-[var(--radius-sm)] transition-all duration-500 group-hover:opacity-80"
|
||||
style={{
|
||||
width: `${Math.max(pct, 4)}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="w-5 text-center text-[13px] font-bold shrink-0"
|
||||
style={{ color }}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
<div className="flex-1 h-6 rounded-[var(--radius-sm)] bg-[var(--bg-hover)] overflow-hidden relative">
|
||||
<div
|
||||
className="h-full rounded-[var(--radius-sm)] transition-all duration-500 group-hover:opacity-80"
|
||||
style={{
|
||||
width: `${Math.max(pct, 4)}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-[13px] shrink-0" style={{ color: 'var(--text-dim)' }}>
|
||||
{val}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-8 text-right text-[13px] shrink-0" style={{ color: 'var(--text-dim)' }}>
|
||||
{val}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{dist.d_profiles && dist.d_profiles.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t" style={{ borderColor: 'var(--border)' }}>
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>D 级 Profile: </span>
|
||||
{dist.d_profiles.map((name) => (
|
||||
<span key={name} className="inline-block text-[12px] px-2 py-0.5 mr-1 mb-1 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)', color: 'var(--red)' }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -186,28 +198,27 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
/* ---------- trend -> timeline events ---------- */
|
||||
const timelineEvents: TimelineEvent[] = trend.map((item) => ({
|
||||
time: hourLabel(item.hour),
|
||||
type: item.heartbeat_timeouts > 0 ? 'red' : item.feishu_disconnects > 0 ? 'orange' : item.restarts > 0 ? 'orange' : 'green',
|
||||
label: (
|
||||
<span className="flex gap-2 text-[11px]">
|
||||
{item.restarts > 0 && <span style={{ color: 'var(--yellow)' }}>R:{item.restarts}</span>}
|
||||
{item.feishu_disconnects > 0 && <span style={{ color: 'var(--orange)' }}>F:{item.feishu_disconnects}</span>}
|
||||
{item.heartbeat_timeouts > 0 && <span style={{ color: 'var(--red)' }}>H:{item.heartbeat_timeouts}</span>}
|
||||
{item.restarts === 0 && item.feishu_disconnects === 0 && item.heartbeat_timeouts === 0 && (
|
||||
<span style={{ color: 'var(--green)' }}>OK</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
color: trendColor(item),
|
||||
}))
|
||||
const timelineEvents: TimelineEvent[] = trend.map((item) => {
|
||||
const details: string[] = []
|
||||
if (item.heartbeat_timeouts > 0) details.push(`心跳超时 ${item.heartbeat_timeouts}`)
|
||||
if (item.feishu_disconnects > 0) details.push(`飞书断连 ${item.feishu_disconnects}`)
|
||||
if (item.restarts > 0) details.push(`重启 ${item.restarts}`)
|
||||
const tooltipText = details.length > 0 ? details.join(' / ') : '正常'
|
||||
|
||||
return {
|
||||
time: hourLabel(item.hour),
|
||||
type: item.heartbeat_timeouts > 0 ? 'red' : item.feishu_disconnects > 0 ? 'orange' : item.restarts > 0 ? 'orange' : 'green',
|
||||
tooltip: tooltipText,
|
||||
color: trendColor(item),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
|
||||
|
||||
{/* ---- summary cards ---- */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-[14px] mt-4">
|
||||
<Card
|
||||
label="在线 Gateway"
|
||||
value={summary.online_count}
|
||||
@@ -237,7 +248,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* ---- health + trend ---- */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[14px] mt-4">
|
||||
{/* health distribution */}
|
||||
<div
|
||||
className="rounded-[var(--radius)] p-4"
|
||||
@@ -281,7 +292,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* ---- kanban + cron ---- */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-[14px] mt-4">
|
||||
{/* kanban overview */}
|
||||
<div
|
||||
className="rounded-[var(--radius)] p-4"
|
||||
|
||||
@@ -42,12 +42,7 @@ const eventToTimeline = (e: GatewayEvent): TimelineEvent => {
|
||||
return {
|
||||
time: e.time,
|
||||
type: e.type,
|
||||
label: (
|
||||
<div className="flex flex-col items-center text-[11px]">
|
||||
<span className="font-medium" style={{ color: 'var(--text)' }}>{labelMap[e.type] || e.type}</span>
|
||||
<span style={{ color: 'var(--text-dim)' }}>{e.profile}</span>
|
||||
</div>
|
||||
),
|
||||
tooltip: `${labelMap[e.type] || e.type} — ${e.profile}`,
|
||||
color: colorMap[e.type],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user