From c79b1af392712271bb28765a9428d33158a1dd7f Mon Sep 17 00:00:00 2001 From: enzo Date: Wed, 22 Jul 2026 22:03:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20pixel-perfect=20rewrite=20=E2=80=94=20L?= =?UTF-8?q?ogin/Sidebar/Header/Dashboard/Gateway?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Login: 400px card with 24px radius, white 28px logo, password eye toggle, 42px inputs, 48px honey button, placeholder var(--text-muted) - Sidebar: NEW badges golden, count badges red, hover bg-hover, active honey-glow - Header: search with search icon, status dots, inline hover dropdown - Dashboard: 4-column stat grid, horizontal stacked health bar (8px height), 48-bar gateway trend (no dupes), tables with height 44px and padding 10px 14px - Gateway: "Gateway 健康监控" title, stat summary, honey filter buttons, honey-outline action buttons, search + type filter, last-update timestamp All colors from var(--*), spacing in 4px multiples, animations ≤300ms, table rows 44px, shadows only 3 layers. Co-Authored-By: Claude --- frontend/src/components/layout/Header.tsx | 185 +++++--- frontend/src/components/layout/Sidebar.tsx | 14 +- frontend/src/pages/DashboardPage.tsx | 507 ++++++++++++++------- frontend/src/pages/GatewayPage.tsx | 261 ++++++++--- frontend/src/pages/LoginPage.tsx | 164 +++++-- frontend/src/styles/tokens.css | 210 ++++++--- 6 files changed, 962 insertions(+), 379 deletions(-) diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 923ba25..7cdd82d 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,5 +1,7 @@ +import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useAuth } from '../../hooks/useAuth' +import { Search } from 'lucide-react' export interface HeaderProps { breadcrumb?: string @@ -24,6 +26,7 @@ export default function Header({ const navigate = useNavigate() const { user, logout } = useAuth() const initial = user?.id ? user.id.charAt(0).toUpperCase() : '?' + const [menuOpen, setMenuOpen] = useState(false) const handleSearchKeyDown = (e: React.KeyboardEvent) => { const value = (e.target as HTMLInputElement).value.trim() @@ -39,6 +42,7 @@ export default function Header({
{/* Logo */} - + 🐝 知微蜂巢 {/* Breadcrumb */} - / {breadcrumb || '蜂群总览'} + /{' '} + + {breadcrumb || '蜂群总览'} + {/* Spacer */}
{/* Search */} - { e.currentTarget.style.borderColor = 'var(--honey)' }} - onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }} - /> +
+ + { + e.currentTarget.style.borderColor = 'var(--honey)' + }} + onBlur={(e) => { + e.currentTarget.style.borderColor = 'var(--border)' + }} + /> +
{/* Status badges */} - 🟢 {onlineCount} online + ● + {onlineCount} online - 🟡 {runningCount} running + ● + {runningCount} running {/* Theme toggle */} @@ -125,46 +175,69 @@ export default function Header({ {/* User avatar */}
setMenuOpen(true)} + onMouseLeave={() => setMenuOpen(false)} >
{initial}
-
{ e.currentTarget.style.display = 'block' }} - onMouseLeave={(e) => { e.currentTarget.style.display = 'none' }} - > - -
+ +
+ )}
) diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9e0d963..e1b18c1 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -161,12 +161,12 @@ export default function Sidebar({ activePath, onNavigate }: SidebarProps) { {/* Section items */} {visible.map((item) => { const isActive = activePath === item.path - const itemBadge = item.path === '/cron' ? (badge: number | undefined) => { - // For cron, combine static badge with live failures - const b = cronFailures > 0 ? cronFailures : item.badge - return b - } : () => item.badge - const finalBadge = item.path === '/cron' ? (cronFailures > 0 ? cronFailures : item.badge) : item.badge + const finalBadge = + item.path === '/cron' + ? cronFailures > 0 + ? cronFailures + : item.badge + : item.badge return ( @@ -251,8 +343,7 @@ export default function DashboardPage() { ) } - /* subtitle */ - var subtitle = summary.total_profiles + ' 个 Profile' + (summary.updated_at ? ' · 最后更新 ' + timeAgo(summary.updated_at) : '') + const subtitle = summary.total_profiles + ' 个 Profile' + (summary.updated_at ? ' · 最后更新 ' + timeAgo(summary.updated_at) : '') return (
@@ -260,28 +351,55 @@ export default function DashboardPage() {
蜂群总览
{subtitle}
- {/* stat cards */} -
- - + {/* stat cards: 4-column grid */} +
+ +
- {/* health + trend */} + {/* health + trend: 3-column layout (2fr 1fr) */}
{/* health distribution */} -
-
+
+
蜂群健康分布 - + navigate('/health')} + style={{ color: 'var(--text-dim)', fontSize: 12, cursor: 'pointer' }} + > 查看全部 →
{health ? (
- ⚠ {health.d_profiles.length} 个 D 级 Profile: @@ -296,77 +414,150 @@ export default function DashboardPage() {
{/* gateway trend */} -
-
+
+
Gateway 异常趋势 (24h)
{trend.length > 0 ? :

暂无数据

} -
- ● 正常运行 - ● 心跳超时 - ● 飞书断连 -
- {/* kanban + cron */} + {/* kanban + cron: 2-column grid */}
{/* kanban overview */} -
-
+
+
Kanban 任务概览
-
-
- 看板 - RUNNING - READY -
- {kanban.length > 0 ? kanban.map(function(item) { - return ( -
- {item.board} - {item.running || '—'} - {item.ready || '—'} -
- ) - }) :

暂无数据

} +
+ + + + + + + + + + {kanban.length > 0 ? kanban.map((item) => ( + navigate('/kanban')} + style={{ cursor: 'pointer', height: 44 }} + > + + + + + )) : ( + + + + )} + +
看板RUNNINGREADY
{item.board}{item.running || '—'}{item.ready || '—'}
暂无数据
{/* cron recent */} -
-
+
+
Cron 最近执行
-
-
- Job - 状态 - 时间 -
- {cron.length > 0 ? cron.map(function(item) { - return ( -
- {item.job} - - {item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'} - - {item.time || '—'} -
- ) - }) :

暂无数据

} +
+ + + + + + + + + + {cron.length > 0 ? cron.map((item) => ( + navigate('/cron')} + style={{ cursor: 'pointer', height: 44 }} + > + + + + + )) : ( + + + + )} + +
Job状态时间
{item.job} + {item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'} + {item.time || '—'}
暂无数据
{/* recent events table */} -
-
+
+
最近异常事件
@@ -381,21 +572,25 @@ export default function DashboardPage() { - {MOCK_EVENTS.map(function(evt, i) { - return ( - - {evt.time} - {evt.profile} - - {evt.type} - - {evt.summary} - - {evt.status} - - - ) - })} + {MOCK_EVENTS.map((evt, i) => ( + + {evt.time} + {evt.profile} + + {evt.type} + + {evt.summary} + + {evt.status} + + + ))}
diff --git a/frontend/src/pages/GatewayPage.tsx b/frontend/src/pages/GatewayPage.tsx index 039884c..44a1020 100644 --- a/frontend/src/pages/GatewayPage.tsx +++ b/frontend/src/pages/GatewayPage.tsx @@ -1,8 +1,7 @@ import { useState, useCallback, useEffect } from 'react' -import { RotateCcw, Play, Square, AlertTriangle } from 'lucide-react' +import { RotateCcw, Play, Square, AlertTriangle, Search } from 'lucide-react' import Table, { type TableColumn } from '../components/ui/Table' import Badge from '../components/ui/Badge' -import Button from '../components/ui/Button' import Modal from '../components/ui/Modal' import Skeleton from '../components/ui/Skeleton' import Timeline, { type TimelineEvent } from '../components/ui/Timeline' @@ -60,8 +59,10 @@ export default function GatewayPage() { const [events, setEvents] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [lastUpdate, setLastUpdate] = useState(new Date()) const [statusFilter, setStatusFilter] = useState('all') + const [searchQuery, setSearchQuery] = useState('') const [typeFilter, setTypeFilter] = useState('all') const [operating, setOperating] = useState(null) @@ -71,6 +72,7 @@ export default function GatewayPage() { try { const res = await getGatewayStatus() setGateways(res.data) + setLastUpdate(new Date()) setError(null) } catch { setError('加载 Gateway 状态失败') @@ -110,10 +112,20 @@ export default function GatewayPage() { } }, [modalAction, fetchStatus]) + /* ---- stats ---- */ + const totalCount = gateways.length + const onlineCount = gateways.filter(g => g.status === 'online').length + const offlineCount = gateways.filter(g => g.status === 'offline').length + const warnCount = gateways.filter(g => g.status === 'warn').length + /* ---- filter ---- */ const filtered = gateways.filter((g) => { if (statusFilter !== 'all' && g.status !== statusFilter) return false if (typeFilter !== 'all' && g.profile_type !== typeFilter) return false + if (searchQuery.trim()) { + const q = searchQuery.trim().toLowerCase() + if (!g.name.toLowerCase().includes(q)) return false + } return true }) @@ -138,36 +150,69 @@ export default function GatewayPage() { ) : '-', last_event: gw.last_event || '-', actions: canOperate ? ( -
- + {gw.status === 'online' ? ( - + ) : ( - + )}
) : null, @@ -180,12 +225,23 @@ export default function GatewayPage() { const timelineEvents = events.map(eventToTimeline) + /* ---- last update text ---- */ + const secondsAgo = Math.floor((Date.now() - lastUpdate.getTime()) / 1000) + + /* ---- status filter buttons ---- */ + const statusFilters = [ + { key: 'all', label: '全部' }, + { key: 'online', label: '在线\u{1F535}' }, + { key: 'offline', label: '离线\u{1F534}' }, + { key: 'warn', label: '异常\u{1F7E0}' }, + ] + /* ---------- render ---------- */ if (loading) { return (
-

Gateway 管理

+

Gateway 健康监控

@@ -195,13 +251,27 @@ export default function GatewayPage() { if (error && gateways.length === 0) { return (
-

Gateway 管理

+

Gateway 健康监控

{error}

- +
) @@ -209,12 +279,32 @@ export default function GatewayPage() { return (
-

Gateway 管理

+ {/* Title + stats */} +
+
+

+ Gateway 健康监控 +

+
+ 共 {totalCount} 个 · 在线 {onlineCount} · 离线 {offlineCount} · 异常 {warnCount} +
+
+
+ 最后更新 {secondsAgo} 秒前 +
+
{/* ---- event timeline ---- */} {timelineEvents.length > 0 && (

@@ -232,51 +322,102 @@ export default function GatewayPage() { )} {/* ---- filter bar ---- */} -
- - - {gateways.length > 0 && ( - - 共 {filtered.length} / {gateways.length} 个 Gateway - - )} +
+ {/* status filter buttons */} +
+ {statusFilters.map((f) => ( + + ))} +
+ + {/* search + type filter */} +
+
+ + setSearchQuery(e.target.value)} + style={{ + width: 200, + height: 32, + padding: '0 12px 0 30px', + borderRadius: 'var(--radius-sm)', + border: '1px solid var(--border)', + background: 'var(--bg-card)', + color: 'var(--text)', + fontSize: 13, + outline: 'none', + fontFamily: 'inherit', + }} + /> +
+ +
{/* ---- table ---- */} -
+
{filtered.length === 0 && !loading ? (
-

全部 Gateway 正常运行 🎉

+

全部 Gateway 正常运行

) : ( +
{/* Logo */} -

- 🐝 知微蜂巢 -

-

- Hive Manage v3 -

+
+
+ 🐝 知微蜂巢 +
+
+ Hive Manage v3 +
+
{/* Error */} {error && (
{error}
)} {/* Form */} -
+
-
-
- + {/* Register link */} -

+

还没有账号?{' '} 注册 diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css index c535cdb..372ab89 100644 --- a/frontend/src/styles/tokens.css +++ b/frontend/src/styles/tokens.css @@ -1,83 +1,161 @@ -/* ===== 暗色模式(默认) ===== */ +/* ============================================================= + * Hive Manage v2 — Design Tokens (CSS Variables) + * 基于 UI-design-spec.md v2.1 + * + * 使用方式: + * 暗色模式(默认): 直接引入本文件 + * 浅色模式: 在 上加 data-theme="light" + * 跟随系统: 用 @media (prefers-color-scheme: light) 覆盖 + * ============================================================= */ + +/* === 暗色模式(默认) === */ :root { - --bg-root: #0f1117; - --bg-nav: #161822; - --bg-card: #1a1d2e; - --bg-hover: #1e2134; + /* 背景层级 */ + --bg-root: #0f1117; + --bg-nav: #161822; + --bg-card: #1a1d2e; + --bg-hover: #1e2134; --bg-elevated: #222639; - --border: #2a2d3e; - --border-light: rgba(42, 45, 62, 0.5); - --honey: #f0b90b; - --honey-dim: #b8970e; - --honey-glow: rgba(240, 185, 11, 0.12); - --green: #00c853; - --red: #ff3d3d; - --yellow: #ffab00; - --blue: #448aff; - --orange: #ff6d00; - --text: #e0e0e0; - --text-dim: #8b8d9a; - --text-muted: #5a5d6a; - --bg-success: #0d2818; - --bg-error: #2d0f0f; - --bg-warn: #2d2000; - --bg-info: #0d1b2d; - --radius: 8px; - --radius-sm: 4px; - --radius-lg: 12px; + + /* 边框 */ + --border: #2a2d3e; + --border-light: rgba(42,45,62,0.5); + + /* 品牌色 */ + --honey: #f0b90b; + --honey-dim: #b8970e; + --honey-glow: rgba(240,185,11,0.12); + + /* 功能色 */ + --green: #00c853; + --red: #ff3d3d; + --yellow: #ffab00; + --blue: #448aff; + --orange: #ff6d00; + + /* 文字 */ + --text: #e0e0e0; + --text-dim: #8b8d9a; + --text-muted: #5a5d6a; + + /* 语义背景 */ + --bg-success: #0d2818; + --bg-error: #2d0f0f; + --bg-warn: #2d2000; + --bg-info: #0d1b2d; + + /* 圆角 */ + --radius: 8px; + --radius-sm: 4px; + --radius-lg: 12px; --radius-full: 9999px; - --shadow: 0 2px 8px rgba(0, 0, 0, 0.35); - --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55); - --shadow-modal: 0 24px 64px rgba(0, 0, 0, 0.65); - --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - --font-mono: "SF Mono", "Fira Code", Consolas, monospace; - --header-h: 48px; - --sidebar-w: 220px; + + /* 阴影 */ + --shadow: 0 2px 8px rgba(0,0,0,0.35); + --shadow-lg: 0 12px 32px rgba(0,0,0,0.55); + --shadow-modal: 0 24px 64px rgba(0,0,0,0.65); + + /* 间距 */ + --space-xs: 4px; + --space-sm: 8px; + --space-md: 12px; + --space-lg: 16px; + --space-xl: 20px; + --space-2xl: 24px; + --space-3xl: 32px; + + /* 字体 */ + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: "SF Mono", "Fira Code", Consolas, monospace; } -/* ===== 浅色模式 ===== */ +/* === 浅色模式 === */ [data-theme="light"] { - --bg-root: #f5f6fa; - --bg-nav: #ffffff; - --bg-card: #ffffff; - --bg-hover: #edf0f5; + --bg-root: #f5f6fa; + --bg-nav: #ffffff; + --bg-card: #ffffff; + --bg-hover: #edf0f5; --bg-elevated: #ffffff; - --border: #e2e5ee; - --border-light: rgba(0, 0, 0, 0.06); - --honey: #e5a800; - --honey-dim: #c48f00; - --honey-glow: rgba(229, 168, 0, 0.08); - --green: #008c3a; - --red: #d32f2f; - --yellow: #e65100; - --blue: #1565c0; - --orange: #d84315; - --text: #1a1d2e; - --text-dim: #555a6e; - --text-muted: #8b8f9e; - --bg-success: #e8f5e9; - --bg-error: #fdecea; - --bg-warn: #fff3e0; - --bg-info: #e3f2fd; - --shadow: 0 1px 4px rgba(0, 0, 0, 0.08); - --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1); - --shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.14); + + --border: #e2e5ee; + --border-light: rgba(0,0,0,0.06); + + --honey: #e5a800; + --honey-dim: #c48f00; + --honey-glow: rgba(229,168,0,0.08); + + --green: #008c3a; + --red: #d32f2f; + --yellow: #e65100; + --blue: #1565c0; + --orange: #d84315; + + --text: #1a1d2e; + --text-dim: #555a6e; + --text-muted: #8b8f9e; + + --bg-success: #e8f5e9; + --bg-error: #fdecea; + --bg-warn: #fff3e0; + --bg-info: #e3f2fd; + + --shadow: 0 1px 4px rgba(0,0,0,0.08); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.10); + --shadow-modal: 0 16px 48px rgba(0,0,0,0.14); } -/* ===== 全局基础 ===== */ +/* === 自动跟随系统 === */ +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --bg-root: #f5f6fa; + --bg-nav: #ffffff; + --bg-card: #ffffff; + --bg-hover: #edf0f5; + --bg-elevated: #ffffff; + --border: #e2e5ee; + --border-light: rgba(0,0,0,0.06); + --honey: #e5a800; + --honey-dim: #c48f00; + --honey-glow: rgba(229,168,0,0.08); + --green: #008c3a; + --red: #d32f2f; + --yellow: #e65100; + --blue: #1565c0; + --orange: #d84315; + --text: #1a1d2e; + --text-dim: #555a6e; + --text-muted: #8b8f9e; + --bg-success: #e8f5e9; + --bg-error: #fdecea; + --bg-warn: #fff3e0; + --bg-info: #e3f2fd; + --shadow: 0 1px 4px rgba(0,0,0,0.08); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.10); + --shadow-modal: 0 16px 48px rgba(0,0,0,0.14); + } +} + +/* === 主题切换过渡 === */ * { + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; +} + +/* === 全局 Reset === */ +*, *::before, *::after { + margin: 0; + padding: 0; box-sizing: border-box; } -body { - margin: 0; - font-family: var(--font); - font-size: 13px; +html, body { + font-family: var(--font-sans); background: var(--bg-root); color: var(--text); + font-size: 13px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } -/* 主题切换过渡 */ -* { - transition: background-color 0.15s, border-color 0.15s, color 0.15s; -} +/* Backward compat aliases */ +:root { --font: var(--font-sans); --header-h: 48px; --sidebar-w: 220px; }