feat: pixel-perfect rewrite — Login/Sidebar/Header/Dashboard/Gateway
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../../hooks/useAuth'
|
import { useAuth } from '../../hooks/useAuth'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
export interface HeaderProps {
|
export interface HeaderProps {
|
||||||
breadcrumb?: string
|
breadcrumb?: string
|
||||||
@@ -24,6 +26,7 @@ export default function Header({
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const initial = user?.id ? user.id.charAt(0).toUpperCase() : '?'
|
const initial = user?.id ? user.id.charAt(0).toUpperCase() : '?'
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
|
||||||
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
const value = (e.target as HTMLInputElement).value.trim()
|
const value = (e.target as HTMLInputElement).value.trim()
|
||||||
@@ -39,6 +42,7 @@ export default function Header({
|
|||||||
<header
|
<header
|
||||||
style={{
|
style={{
|
||||||
gridArea: 'header',
|
gridArea: 'header',
|
||||||
|
height: 'var(--header-h)',
|
||||||
background: 'var(--bg-nav)',
|
background: 'var(--bg-nav)',
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -46,41 +50,66 @@ export default function Header({
|
|||||||
padding: '0 20px',
|
padding: '0 20px',
|
||||||
gap: 16,
|
gap: 16,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
height: 'var(--header-h)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--honey)', whiteSpace: 'nowrap' }}>
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'var(--honey)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
🐝 知微蜂巢
|
🐝 知微蜂巢
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
||||||
/ <span style={{ color: 'var(--text)' }}>{breadcrumb || '蜂群总览'}</span>
|
/{' '}
|
||||||
|
<span style={{ color: 'var(--text)' }}>
|
||||||
|
{breadcrumb || '蜂群总览'}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Spacer */}
|
{/* Spacer */}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<input
|
<div style={{ position: 'relative' }}>
|
||||||
className="global-search"
|
<Search
|
||||||
placeholder="🔍 搜索 Profile / 任务 / Cron / 事件..."
|
size={14}
|
||||||
onKeyDown={handleSearchKeyDown}
|
style={{
|
||||||
style={{
|
position: 'absolute',
|
||||||
background: 'var(--bg-card)',
|
left: 10,
|
||||||
border: '1px solid var(--border)',
|
top: '50%',
|
||||||
borderRadius: 'var(--radius-sm)',
|
transform: 'translateY(-50%)',
|
||||||
padding: '6px 12px',
|
color: 'var(--text-muted)',
|
||||||
color: 'var(--text)',
|
}}
|
||||||
width: 260,
|
/>
|
||||||
fontSize: 13,
|
<input
|
||||||
outline: 'none',
|
placeholder="搜索 Profile / 任务 / Cron / 事件..."
|
||||||
fontFamily: 'inherit',
|
onKeyDown={handleSearchKeyDown}
|
||||||
}}
|
style={{
|
||||||
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--honey)' }}
|
width: 260,
|
||||||
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
|
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',
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--honey)'
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = 'var(--border)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Status badges */}
|
{/* Status badges */}
|
||||||
<span
|
<span
|
||||||
@@ -92,9 +121,13 @@ export default function Header({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: 'var(--text-dim)',
|
color: 'var(--text-dim)',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🟢 {onlineCount} online
|
<span style={{ color: 'var(--green)' }}>●</span>
|
||||||
|
{onlineCount} online
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -105,19 +138,36 @@ export default function Header({
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: 'var(--text-dim)',
|
color: 'var(--text-dim)',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
🟡 {runningCount} running
|
<span style={{ color: 'var(--yellow)' }}>●</span>
|
||||||
|
{runningCount} running
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
className="cursor-pointer bg-transparent border-none text-[18px] leading-none p-1 rounded hover:bg-[var(--bg-hover)] transition-colors"
|
|
||||||
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
|
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
|
||||||
style={{ background: 'transparent', border: 'none', fontSize: 18, cursor: 'pointer', padding: 4, borderRadius: 'var(--radius-sm)', color: 'var(--text-dim)', lineHeight: 1 }}
|
style={{
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
background: 'transparent',
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
|
border: 'none',
|
||||||
|
fontSize: 18,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4,
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
lineHeight: 1,
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? '☀️' : '🌙'}
|
{theme === 'dark' ? '☀️' : '🌙'}
|
||||||
</button>
|
</button>
|
||||||
@@ -125,46 +175,69 @@ export default function Header({
|
|||||||
{/* User avatar */}
|
{/* User avatar */}
|
||||||
<div
|
<div
|
||||||
style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}
|
style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}
|
||||||
className="group"
|
onMouseEnter={() => setMenuOpen(true)}
|
||||||
|
onMouseLeave={() => setMenuOpen(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 30, height: 30, borderRadius: '50%',
|
width: 30,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
height: 30,
|
||||||
fontSize: 13, fontWeight: 600,
|
borderRadius: '50%',
|
||||||
background: 'var(--honey-glow)', color: 'var(--honey)',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: 'var(--honey-glow)',
|
||||||
|
color: 'var(--honey)',
|
||||||
}}
|
}}
|
||||||
title={user?.role ?? ''}
|
title={user?.role ?? ''}
|
||||||
>
|
>
|
||||||
{initial}
|
{initial}
|
||||||
</div>
|
</div>
|
||||||
<div
|
{menuOpen && (
|
||||||
style={{
|
<div
|
||||||
display: 'none',
|
|
||||||
position: 'absolute', right: 0, top: '100%', marginTop: 4,
|
|
||||||
width: 120, borderRadius: 'var(--radius)',
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
padding: '4px 0',
|
|
||||||
background: 'var(--bg-nav)', border: '1px solid var(--border)',
|
|
||||||
zIndex: 50,
|
|
||||||
}}
|
|
||||||
className="group-hover-block"
|
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.display = 'block' }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.display = 'none' }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => { logout(); navigate('/login') }}
|
|
||||||
style={{
|
style={{
|
||||||
width: '100%', textAlign: 'left', padding: '6px 12px',
|
position: 'absolute',
|
||||||
fontSize: 13, border: 'none', background: 'transparent',
|
right: 0,
|
||||||
color: 'var(--red)', cursor: 'pointer', fontFamily: 'inherit',
|
top: '100%',
|
||||||
|
marginTop: 4,
|
||||||
|
width: 120,
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
padding: '4px 0',
|
||||||
|
background: 'var(--bg-nav)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
zIndex: 50,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
|
||||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
|
|
||||||
>
|
>
|
||||||
退出登录
|
<button
|
||||||
</button>
|
onClick={() => {
|
||||||
</div>
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '6px 12px',
|
||||||
|
fontSize: 13,
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--red)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -161,12 +161,12 @@ export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
|
|||||||
{/* Section items */}
|
{/* Section items */}
|
||||||
{visible.map((item) => {
|
{visible.map((item) => {
|
||||||
const isActive = activePath === item.path
|
const isActive = activePath === item.path
|
||||||
const itemBadge = item.path === '/cron' ? (badge: number | undefined) => {
|
const finalBadge =
|
||||||
// For cron, combine static badge with live failures
|
item.path === '/cron'
|
||||||
const b = cronFailures > 0 ? cronFailures : item.badge
|
? cronFailures > 0
|
||||||
return b
|
? cronFailures
|
||||||
} : () => item.badge
|
: item.badge
|
||||||
const finalBadge = item.path === '/cron' ? (cronFailures > 0 ? cronFailures : item.badge) : item.badge
|
: item.badge
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -187,7 +187,7 @@ export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
transition: 'all .15s',
|
transition: 'all 0.15s',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
import Skeleton from '../components/ui/Skeleton'
|
import Skeleton from '../components/ui/Skeleton'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -26,13 +27,6 @@ function timeAgo(iso: string): string {
|
|||||||
return Math.floor(m / 60) + ' 小时前'
|
return Math.floor(m / 60) + ' 小时前'
|
||||||
}
|
}
|
||||||
|
|
||||||
const VAL_COLORS: Record<string, string> = {
|
|
||||||
green: 'var(--green)',
|
|
||||||
red: 'var(--red)',
|
|
||||||
yellow: 'var(--yellow)',
|
|
||||||
blue: 'var(--blue)',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RecentEvent {
|
interface RecentEvent {
|
||||||
time: string
|
time: string
|
||||||
profile: string
|
profile: string
|
||||||
@@ -57,19 +51,49 @@ function StatCard({
|
|||||||
label: string; value: string | number; valueColor?: string
|
label: string; value: string | number; valueColor?: string
|
||||||
change?: string; changeDirection?: 'up' | 'down'
|
change?: string; changeDirection?: 'up' | 'down'
|
||||||
}) {
|
}) {
|
||||||
const dirIcon = changeDirection === 'up' ? '↑' : '↓'
|
const iconColor = changeDirection === 'up' ? 'var(--green)' : 'var(--red)'
|
||||||
const dirColor = changeDirection === 'up' ? 'var(--green)' : 'var(--red)'
|
|
||||||
return (
|
return (
|
||||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 16 }}>
|
<div
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 700, color: valueColor ? VAL_COLORS[valueColor] : 'var(--text)' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: valueColor ? `var(--${valueColor})` : 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
{change && (
|
{change && (
|
||||||
<div style={{ fontSize: 12, marginTop: 4, color: changeDirection ? dirColor : 'var(--text-dim)' }}>
|
<div
|
||||||
{changeDirection && <span>{dirIcon} </span>}
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
marginTop: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 2,
|
||||||
|
color: changeDirection ? iconColor : 'var(--text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{changeDirection === 'up' && <ArrowUp size={12} />}
|
||||||
|
{changeDirection === 'down' && <ArrowDown size={12} />}
|
||||||
{change}
|
{change}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -77,7 +101,7 @@ function StatCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Health vertical bar chart ---------- */
|
/* ---------- Health stacked bar chart (horizontal) ---------- */
|
||||||
|
|
||||||
const GRADE_META = [
|
const GRADE_META = [
|
||||||
{ key: 'A', color: 'var(--green)' },
|
{ key: 'A', color: 'var(--green)' },
|
||||||
@@ -86,39 +110,58 @@ const GRADE_META = [
|
|||||||
{ key: 'D', color: 'var(--red)' },
|
{ key: 'D', color: 'var(--red)' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function HealthBarChart({ dist, onBarClick }: { dist: HealthDistribution; onBarClick: (g: string) => void }) {
|
function HealthStackedBar({ dist, onBarClick }: { dist: HealthDistribution; onBarClick: (g: string) => void }) {
|
||||||
if (!dist || dist.total === 0) {
|
if (!dist || dist.total === 0) {
|
||||||
return <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
return <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
||||||
}
|
}
|
||||||
const maxVal = Math.max(dist.A, dist.B, dist.C, dist.D, 1)
|
const total = dist.A + dist.B + dist.C + dist.D
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, height: 120, padding: '0 8px' }}>
|
<div>
|
||||||
{GRADE_META.map(function(meta) {
|
<div
|
||||||
var val = dist[meta.key];
|
style={{
|
||||||
var pct = (val / maxVal) * 100;
|
display: 'flex',
|
||||||
return (
|
height: 8,
|
||||||
<div key={meta.key} onClick={function() { onBarClick(meta.key) }}
|
borderRadius: 4,
|
||||||
style={{
|
overflow: 'hidden',
|
||||||
flex: 1, borderRadius: '4px 4px 0 0', textAlign: 'center', position: 'relative',
|
marginBottom: 12,
|
||||||
minWidth: 40, cursor: 'pointer', height: Math.max(pct, 4) + '%', backgroundColor: meta.color,
|
}}
|
||||||
transition: 'opacity .15s',
|
>
|
||||||
}}
|
{GRADE_META.map((meta) => {
|
||||||
onMouseEnter={function(e) { e.currentTarget.style.opacity = '0.8' }}
|
const val = dist[meta.key as keyof HealthDistribution] as number
|
||||||
onMouseLeave={function(e) { e.currentTarget.style.opacity = '1' }}
|
if (val === 0) return null
|
||||||
>
|
const pct = total > 0 ? (val / total) * 100 : 0
|
||||||
<span style={{ position: 'absolute', top: -18, left: '50%', transform: 'translateX(-50%)', fontSize: 12, fontWeight: 700, color: 'var(--text)' }}>
|
return (
|
||||||
{val}
|
<div
|
||||||
</span>
|
key={meta.key}
|
||||||
<span style={{ position: 'absolute', bottom: -22, left: '50%', transform: 'translateX(-50%)', fontSize: 11, color: 'var(--text-dim)' }}>
|
onClick={() => onBarClick(meta.key)}
|
||||||
{meta.key}级
|
style={{
|
||||||
</span>
|
width: pct + '%',
|
||||||
</div>
|
height: '100%',
|
||||||
)
|
backgroundColor: meta.color,
|
||||||
})}
|
cursor: 'pointer',
|
||||||
|
transition: 'opacity 0.15s',
|
||||||
|
}}
|
||||||
|
title={meta.key + '级: ' + val}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 20, fontSize: 12 }}>
|
||||||
|
{GRADE_META.map((meta) => {
|
||||||
|
const val = dist[meta.key as keyof HealthDistribution] as number
|
||||||
|
return (
|
||||||
|
<div key={meta.key} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ color: meta.color }}>●</span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>{meta.key} {val}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ---------- Gateway 48-bar timeline ---------- */
|
/* ---------- Gateway 48-bar timeline ---------- */
|
||||||
|
|
||||||
function barColor(item: GatewayTrendItem): string {
|
function barColor(item: GatewayTrendItem): string {
|
||||||
@@ -128,32 +171,51 @@ function barColor(item: GatewayTrendItem): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GatewayTimeline({ trend }: { trend: GatewayTrendItem[] }) {
|
function GatewayTimeline({ trend }: { trend: GatewayTrendItem[] }) {
|
||||||
var bars = [];
|
|
||||||
for (var i = 0; i < trend.length; i++) {
|
|
||||||
var item = trend[i];
|
|
||||||
var col = barColor(item);
|
|
||||||
var details = [];
|
|
||||||
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);
|
|
||||||
var tip = details.length > 0 ? details.join(' / ') : '正常运行';
|
|
||||||
bars.push({ color: col, tooltip: item.hour + ': ' + tip });
|
|
||||||
bars.push({ color: col, tooltip: item.hour + ': ' + tip });
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: 2, height: 40, alignItems: 'flex-end', marginBottom: 12 }}>
|
<div>
|
||||||
{bars.map(function(b, i) {
|
<div
|
||||||
return (
|
style={{
|
||||||
<div key={i} title={b.tooltip}
|
display: 'flex',
|
||||||
style={{
|
gap: 2,
|
||||||
flex: 1, borderRadius: '2px 2px 0 0', minWidth: 3, cursor: 'pointer', position: 'relative',
|
height: 40,
|
||||||
backgroundColor: b.color,
|
alignItems: 'flex-end',
|
||||||
}}
|
marginBottom: 12,
|
||||||
onMouseEnter={function(e) { e.currentTarget.style.opacity = '0.7' }}
|
}}
|
||||||
onMouseLeave={function(e) { e.currentTarget.style.opacity = '1' }}
|
>
|
||||||
/>
|
{trend.map((item, i) => {
|
||||||
)
|
const col = barColor(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 tip = details.length > 0 ? details.join(' / ') : '正常运行'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
title={item.hour + ': ' + tip}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: '2px 2px 0 0',
|
||||||
|
minWidth: 3,
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: col,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 16,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span><span style={{ color: 'var(--green)' }}>●</span> 正常运行</span>
|
||||||
|
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
|
||||||
|
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -161,14 +223,23 @@ function GatewayTimeline({ trend }: { trend: GatewayTrendItem[] }) {
|
|||||||
/* ---------- Badge helpers for events table ---------- */
|
/* ---------- Badge helpers for events table ---------- */
|
||||||
|
|
||||||
function BadgeSpan({ cls, children }: { cls: string; children: React.ReactNode }) {
|
function BadgeSpan({ cls, children }: { cls: string; children: React.ReactNode }) {
|
||||||
var styleMap: Record<string, React.CSSProperties> = {
|
const styleMap: Record<string, React.CSSProperties> = {
|
||||||
'badge-offline': { background: 'rgba(255,61,61,.15)', color: 'var(--red)' },
|
'badge-offline': { background: 'rgba(255,61,61,.15)', color: 'var(--red)' },
|
||||||
'badge-warn': { background: 'rgba(255,171,0,.15)', color: 'var(--yellow)' },
|
'badge-warn': { background: 'rgba(255,171,0,.15)', color: 'var(--yellow)' },
|
||||||
'badge-a': { background: 'rgba(0,200,83,.15)', color: 'var(--green)' },
|
'badge-a': { background: 'rgba(0,200,83,.15)', color: 'var(--green)' },
|
||||||
}
|
}
|
||||||
var baseStyle: React.CSSProperties = styleMap[cls] || { background: 'var(--bg-hover)', color: 'var(--text-dim)' }
|
const baseStyle: React.CSSProperties = styleMap[cls] || { background: 'var(--bg-hover)', color: 'var(--text-dim)' }
|
||||||
return (
|
return (
|
||||||
<span style={Object.assign({ display: 'inline-block', padding: '2px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600 }, baseStyle)}>
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: 10,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600,
|
||||||
|
...baseStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -177,21 +248,21 @@ function BadgeSpan({ cls, children }: { cls: string; children: React.ReactNode }
|
|||||||
/* ---------- main page ---------- */
|
/* ---------- main page ---------- */
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
var navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
var _a = useState<SummaryData | null>(null), summary = _a[0], setSummary = _a[1]
|
const [summary, setSummary] = useState<SummaryData | null>(null)
|
||||||
var _b = useState<HealthDistribution | null>(null), health = _b[0], setHealth = _b[1]
|
const [health, setHealth] = useState<HealthDistribution | null>(null)
|
||||||
var _c = useState<GatewayTrendItem[]>([]), trend = _c[0], setTrend = _c[1]
|
const [trend, setTrend] = useState<GatewayTrendItem[]>([])
|
||||||
var _d = useState<KanbanSummaryItem[]>([]), kanban = _d[0], setKanban = _d[1]
|
const [kanban, setKanban] = useState<KanbanSummaryItem[]>([])
|
||||||
var _e = useState<CronRecentItem[]>([]), cron = _e[0], setCron = _e[1]
|
const [cron, setCron] = useState<CronRecentItem[]>([])
|
||||||
var _f = useState(true), loading = _f[0], setLoading = _f[1]
|
const [loading, setLoading] = useState(true)
|
||||||
var _g = useState<string | null>(null), error = _g[0], setError = _g[1]
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
var fetchAll = useCallback(async function() {
|
const fetchAll = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
var res = await Promise.all([
|
const res = await Promise.all([
|
||||||
getDashboardSummary(),
|
getDashboardSummary(),
|
||||||
getHealthDistribution(),
|
getHealthDistribution(),
|
||||||
getGatewayTrend(),
|
getGatewayTrend(),
|
||||||
@@ -203,14 +274,14 @@ export default function DashboardPage() {
|
|||||||
setTrend(res[2].data)
|
setTrend(res[2].data)
|
||||||
setKanban(res[3].data)
|
setKanban(res[3].data)
|
||||||
setCron(res[4].data)
|
setCron(res[4].data)
|
||||||
} catch (e) {
|
} catch {
|
||||||
setError('加载仪表盘数据失败')
|
setError('加载仪表盘数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(function() { fetchAll() }, [fetchAll])
|
useEffect(() => { fetchAll() }, [fetchAll])
|
||||||
|
|
||||||
/* loading */
|
/* loading */
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -231,9 +302,30 @@ export default function DashboardPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
|
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
|
||||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 20, textAlign: 'center', marginTop: 20 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: 20,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
||||||
<button onClick={fetchAll} style={{ background: 'var(--honey)', color: '#000', border: 'none', borderRadius: 'var(--radius-sm)', padding: '6px 14px', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
|
<button
|
||||||
|
onClick={fetchAll}
|
||||||
|
style={{
|
||||||
|
background: 'var(--honey)',
|
||||||
|
color: '#000',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,8 +343,7 @@ export default function DashboardPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* subtitle */
|
const subtitle = summary.total_profiles + ' 个 Profile' + (summary.updated_at ? ' · 最后更新 ' + timeAgo(summary.updated_at) : '')
|
||||||
var subtitle = summary.total_profiles + ' 个 Profile' + (summary.updated_at ? ' · 最后更新 ' + timeAgo(summary.updated_at) : '')
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -260,28 +351,55 @@ export default function DashboardPage() {
|
|||||||
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
|
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
|
||||||
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginBottom: 20 }}>{subtitle}</div>
|
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginBottom: 20 }}>{subtitle}</div>
|
||||||
|
|
||||||
{/* stat cards */}
|
{/* stat cards: 4-column grid */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14, marginBottom: 20 }}>
|
<div
|
||||||
<StatCard label='在线 Gateway' value={summary.online_count} valueColor='green' change={'↑ vs 昨天'} changeDirection='up' />
|
style={{
|
||||||
<StatCard label='离线 Gateway' value={summary.offline_count} valueColor='red' change={'↓ vs 昨天'} changeDirection='down' />
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||||
|
gap: 16,
|
||||||
|
marginBottom: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StatCard label='在线 Gateway' value={summary.online_count} valueColor='green' change={'2 vs 昨天'} changeDirection='up' />
|
||||||
|
<StatCard label='离线 Gateway' value={summary.offline_count} valueColor='red' change={'1 vs 昨天'} changeDirection='down' />
|
||||||
<StatCard label='运行中任务' value={summary.running_tasks} valueColor='blue' change={'Kanban · ' + kanban.length + ' 个看板活跃'} />
|
<StatCard label='运行中任务' value={summary.running_tasks} valueColor='blue' change={'Kanban · ' + kanban.length + ' 个看板活跃'} />
|
||||||
<StatCard label='今日 Cron 失败' value={summary.cron_failures_today} valueColor='red' />
|
<StatCard label='今日 Cron 失败' value={summary.cron_failures_today} valueColor='red' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* health + trend */}
|
{/* health + trend: 3-column layout (2fr 1fr) */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginBottom: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginBottom: 20 }}>
|
||||||
{/* health distribution */}
|
{/* health distribution */}
|
||||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
<div
|
||||||
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span>蜂群健康分布</span>
|
<span>蜂群健康分布</span>
|
||||||
<span onClick={function() { navigate('/health') }} style={{ color: 'var(--text-dim)', fontSize: 12, cursor: 'pointer' }}>
|
<span
|
||||||
|
onClick={() => navigate('/health')}
|
||||||
|
style={{ color: 'var(--text-dim)', fontSize: 12, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
查看全部 →
|
查看全部 →
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ padding: 16 }}>
|
||||||
{health ? (
|
{health ? (
|
||||||
<div>
|
<div>
|
||||||
<HealthBarChart dist={health} onBarClick={function(g) { navigate('/health?filter=' + g) }} />
|
<HealthStackedBar dist={health} onBarClick={(g) => navigate('/health?filter=' + g)} />
|
||||||
{health.d_profiles && health.d_profiles.length > 0 ? (
|
{health.d_profiles && health.d_profiles.length > 0 ? (
|
||||||
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
|
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
|
||||||
<span>⚠ {health.d_profiles.length} 个 D 级 Profile:</span>
|
<span>⚠ {health.d_profiles.length} 个 D 级 Profile:</span>
|
||||||
@@ -296,77 +414,150 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* gateway trend */}
|
{/* gateway trend */}
|
||||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
<div
|
||||||
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
Gateway 异常趋势 (24h)
|
Gateway 异常趋势 (24h)
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ padding: 16 }}>
|
||||||
{trend.length > 0 ? <GatewayTimeline trend={trend} /> : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
|
{trend.length > 0 ? <GatewayTimeline trend={trend} /> : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
|
||||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-dim)' }}>
|
|
||||||
<span><span style={{ color: 'var(--green)' }}>●</span> 正常运行</span>
|
|
||||||
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
|
|
||||||
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* kanban + cron */}
|
{/* kanban + cron: 2-column grid */}
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }}>
|
||||||
{/* kanban overview */}
|
{/* kanban overview */}
|
||||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
<div
|
||||||
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
Kanban 任务概览
|
Kanban 任务概览
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-dim)', marginBottom: 12 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<span>看板</span>
|
<thead>
|
||||||
<span>RUNNING</span>
|
<tr>
|
||||||
<span>READY</span>
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>看板</th>
|
||||||
</div>
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>RUNNING</th>
|
||||||
{kanban.length > 0 ? kanban.map(function(item) {
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>READY</th>
|
||||||
return (
|
</tr>
|
||||||
<div key={item.board} onClick={function() { navigate('/kanban') }}
|
</thead>
|
||||||
style={{ fontSize: 12, color: 'var(--text-dim)', padding: '4px 0', borderBottom: '1px solid var(--border-light)', cursor: 'pointer', display: 'flex', justifyContent: 'space-between' }}>
|
<tbody>
|
||||||
<span style={{ color: 'var(--text-dim)' }}>{item.board}</span>
|
{kanban.length > 0 ? kanban.map((item) => (
|
||||||
<span style={{ color: 'var(--green)' }}>{item.running || '—'}</span>
|
<tr
|
||||||
<span style={{ color: 'var(--blue)', marginLeft: 40 }}>{item.ready || '—'}</span>
|
key={item.board}
|
||||||
</div>
|
onClick={() => navigate('/kanban')}
|
||||||
)
|
style={{ cursor: 'pointer', height: 44 }}
|
||||||
}) : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
|
>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{item.board}</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--green)' }}>{item.running || '—'}</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{item.ready || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
)) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} style={{ textAlign: 'center', padding: 20, color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* cron recent */}
|
{/* cron recent */}
|
||||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
<div
|
||||||
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
Cron 最近执行
|
Cron 最近执行
|
||||||
</div>
|
</div>
|
||||||
<div style={{ padding: 16 }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-dim)', marginBottom: 8 }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||||
<span>Job</span>
|
<thead>
|
||||||
<span>状态</span>
|
<tr>
|
||||||
<span>时间</span>
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>Job</th>
|
||||||
</div>
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>状态</th>
|
||||||
{cron.length > 0 ? cron.map(function(item) {
|
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>时间</th>
|
||||||
return (
|
</tr>
|
||||||
<div key={item.job} onClick={function() { navigate('/cron') }}
|
</thead>
|
||||||
style={{ fontSize: 12, padding: '4px 0', borderBottom: '1px solid var(--border-light)', cursor: 'pointer' }}>
|
<tbody>
|
||||||
<span style={{ color: 'var(--text-dim)' }}>{item.job}</span>
|
{cron.length > 0 ? cron.map((item) => (
|
||||||
<span style={{ float: 'right', marginRight: 80 }}>
|
<tr
|
||||||
{item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'}
|
key={item.job}
|
||||||
</span>
|
onClick={() => navigate('/cron')}
|
||||||
<span style={{ float: 'right', color: 'var(--text-muted)' }}>{item.time || '—'}</span>
|
style={{ cursor: 'pointer', height: 44 }}
|
||||||
</div>
|
>
|
||||||
)
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{item.job}</td>
|
||||||
}) : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>
|
||||||
|
{item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-muted)' }}>{item.time || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
)) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} style={{ textAlign: 'center', padding: 20, color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* recent events table */}
|
{/* recent events table */}
|
||||||
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
<div
|
||||||
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
|
style={{
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '14px 16px',
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
最近异常事件
|
最近异常事件
|
||||||
</div>
|
</div>
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
@@ -381,21 +572,25 @@ export default function DashboardPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{MOCK_EVENTS.map(function(evt, i) {
|
{MOCK_EVENTS.map((evt, i) => (
|
||||||
return (
|
<tr
|
||||||
<tr key={i} style={evt.status === '待处理' ? { background: 'rgba(255,61,61,.06)' } : undefined}>
|
key={i}
|
||||||
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text)' }}>{evt.time}</td>
|
style={{
|
||||||
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{evt.profile}</td>
|
height: 44,
|
||||||
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
|
...(evt.status === '待处理' ? { background: 'rgba(255,61,61,.06)' } : {}),
|
||||||
<BadgeSpan cls={evt.typeClass}>{evt.type}</BadgeSpan>
|
}}
|
||||||
</td>
|
>
|
||||||
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{evt.summary}</td>
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text)' }}>{evt.time}</td>
|
||||||
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{evt.profile}</td>
|
||||||
<BadgeSpan cls={evt.statusClass}>{evt.status}</BadgeSpan>
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
|
||||||
</td>
|
<BadgeSpan cls={evt.typeClass}>{evt.type}</BadgeSpan>
|
||||||
</tr>
|
</td>
|
||||||
)
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{evt.summary}</td>
|
||||||
})}
|
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
|
||||||
|
<BadgeSpan cls={evt.statusClass}>{evt.status}</BadgeSpan>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react'
|
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 Table, { type TableColumn } from '../components/ui/Table'
|
||||||
import Badge from '../components/ui/Badge'
|
import Badge from '../components/ui/Badge'
|
||||||
import Button from '../components/ui/Button'
|
|
||||||
import Modal from '../components/ui/Modal'
|
import Modal from '../components/ui/Modal'
|
||||||
import Skeleton from '../components/ui/Skeleton'
|
import Skeleton from '../components/ui/Skeleton'
|
||||||
import Timeline, { type TimelineEvent } from '../components/ui/Timeline'
|
import Timeline, { type TimelineEvent } from '../components/ui/Timeline'
|
||||||
@@ -60,8 +59,10 @@ export default function GatewayPage() {
|
|||||||
const [events, setEvents] = useState<GatewayEvent[]>([])
|
const [events, setEvents] = useState<GatewayEvent[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||||
|
|
||||||
const [operating, setOperating] = useState<string | null>(null)
|
const [operating, setOperating] = useState<string | null>(null)
|
||||||
@@ -71,6 +72,7 @@ export default function GatewayPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await getGatewayStatus()
|
const res = await getGatewayStatus()
|
||||||
setGateways(res.data)
|
setGateways(res.data)
|
||||||
|
setLastUpdate(new Date())
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch {
|
} catch {
|
||||||
setError('加载 Gateway 状态失败')
|
setError('加载 Gateway 状态失败')
|
||||||
@@ -110,10 +112,20 @@ export default function GatewayPage() {
|
|||||||
}
|
}
|
||||||
}, [modalAction, fetchStatus])
|
}, [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 ---- */
|
/* ---- filter ---- */
|
||||||
const filtered = gateways.filter((g) => {
|
const filtered = gateways.filter((g) => {
|
||||||
if (statusFilter !== 'all' && g.status !== statusFilter) return false
|
if (statusFilter !== 'all' && g.status !== statusFilter) return false
|
||||||
if (typeFilter !== 'all' && g.profile_type !== typeFilter) 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
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,36 +150,69 @@ export default function GatewayPage() {
|
|||||||
) : '-',
|
) : '-',
|
||||||
last_event: gw.last_event || '-',
|
last_event: gw.last_event || '-',
|
||||||
actions: canOperate ? (
|
actions: canOperate ? (
|
||||||
<div className="flex gap-1">
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<Button
|
<button
|
||||||
size="sm"
|
disabled={operating === gw.name}
|
||||||
variant="ghost"
|
|
||||||
loading={operating === gw.name}
|
|
||||||
onClick={() => setModalAction({ profile: gw.name, action: 'restart' })}
|
onClick={() => setModalAction({ profile: gw.name, action: 'restart' })}
|
||||||
title="重启"
|
title="重启"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--honey)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--honey)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
opacity: operating === gw.name ? 0.5 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RotateCcw size={13} />
|
<RotateCcw size={13} />
|
||||||
</Button>
|
</button>
|
||||||
{gw.status === 'online' ? (
|
{gw.status === 'online' ? (
|
||||||
<Button
|
<button
|
||||||
size="sm"
|
disabled={operating === gw.name}
|
||||||
variant="ghost"
|
|
||||||
loading={operating === gw.name}
|
|
||||||
onClick={() => setModalAction({ profile: gw.name, action: 'stop' })}
|
onClick={() => setModalAction({ profile: gw.name, action: 'stop' })}
|
||||||
title="停止"
|
title="停止"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--honey)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--honey)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
opacity: operating === gw.name ? 0.5 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Square size={13} />
|
<Square size={13} />
|
||||||
</Button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<button
|
||||||
size="sm"
|
disabled={operating === gw.name}
|
||||||
variant="ghost"
|
|
||||||
loading={operating === gw.name}
|
|
||||||
onClick={() => setModalAction({ profile: gw.name, action: 'start' })}
|
onClick={() => setModalAction({ profile: gw.name, action: 'start' })}
|
||||||
title="启动"
|
title="启动"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--honey)',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
color: 'var(--honey)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px 8px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
opacity: operating === gw.name ? 0.5 : 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Play size={13} />
|
<Play size={13} />
|
||||||
</Button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null,
|
||||||
@@ -180,12 +225,23 @@ export default function GatewayPage() {
|
|||||||
|
|
||||||
const timelineEvents = events.map(eventToTimeline)
|
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 ---------- */
|
/* ---------- render ---------- */
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 管理</h2>
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 健康监控</h2>
|
||||||
<Skeleton variant="card" count={1} className="mt-4" />
|
<Skeleton variant="card" count={1} className="mt-4" />
|
||||||
<Skeleton variant="table" count={5} className="mt-4" />
|
<Skeleton variant="table" count={5} className="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -195,13 +251,27 @@ export default function GatewayPage() {
|
|||||||
if (error && gateways.length === 0) {
|
if (error && gateways.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 管理</h2>
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 健康监控</h2>
|
||||||
<div
|
<div
|
||||||
className="mt-4 p-5 rounded-[var(--radius)] text-center"
|
className="mt-4 p-5 rounded-[var(--radius)] text-center"
|
||||||
style={{ background: 'var(--bg-card)' }}
|
style={{ background: 'var(--bg-card)' }}
|
||||||
>
|
>
|
||||||
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
||||||
<Button variant="primary" onClick={fetchStatus}>重试</Button>
|
<button
|
||||||
|
onClick={fetchStatus}
|
||||||
|
style={{
|
||||||
|
background: 'var(--honey)',
|
||||||
|
color: '#000',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
padding: '6px 14px',
|
||||||
|
fontSize: 13,
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -209,12 +279,32 @@ export default function GatewayPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 管理</h2>
|
{/* Title + stats */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>
|
||||||
|
Gateway 健康监控
|
||||||
|
</h2>
|
||||||
|
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginTop: 4 }}>
|
||||||
|
共 {totalCount} 个 · 在线 {onlineCount} · 离线 {offlineCount} · 异常 {warnCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>
|
||||||
|
最后更新 {secondsAgo} 秒前
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ---- event timeline ---- */}
|
{/* ---- event timeline ---- */}
|
||||||
{timelineEvents.length > 0 && (
|
{timelineEvents.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="mt-4 rounded-[var(--radius)] p-4"
|
className="mb-4 rounded-[var(--radius)] p-4"
|
||||||
style={{ background: 'var(--bg-card)' }}
|
style={{ background: 'var(--bg-card)' }}
|
||||||
>
|
>
|
||||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
||||||
@@ -232,51 +322,102 @@ export default function GatewayPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- filter bar ---- */}
|
{/* ---- filter bar ---- */}
|
||||||
<div className="mt-4 flex items-center gap-3 flex-wrap">
|
<div
|
||||||
<select
|
className="mb-3"
|
||||||
value={statusFilter}
|
style={{
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
display: 'flex',
|
||||||
className="px-3 py-1.5 text-[13px] rounded-[var(--radius-sm)] border cursor-pointer"
|
alignItems: 'center',
|
||||||
style={{
|
justifyContent: 'space-between',
|
||||||
background: 'var(--bg-card)',
|
flexWrap: 'wrap',
|
||||||
color: 'var(--text)',
|
gap: 12,
|
||||||
borderColor: 'var(--border)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{/* status filter buttons */}
|
||||||
<option value="all">全部状态</option>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<option value="online">在线</option>
|
{statusFilters.map((f) => (
|
||||||
<option value="offline">离线</option>
|
<button
|
||||||
<option value="warn">异常</option>
|
key={f.key}
|
||||||
</select>
|
onClick={() => setStatusFilter(f.key)}
|
||||||
<select
|
style={{
|
||||||
value={typeFilter}
|
padding: '6px 14px',
|
||||||
onChange={(e) => setTypeFilter(e.target.value)}
|
borderRadius: 'var(--radius-sm)',
|
||||||
className="px-3 py-1.5 text-[13px] rounded-[var(--radius-sm)] border cursor-pointer"
|
border: 'none',
|
||||||
style={{
|
fontSize: 13,
|
||||||
background: 'var(--bg-card)',
|
cursor: 'pointer',
|
||||||
color: 'var(--text)',
|
fontFamily: 'inherit',
|
||||||
borderColor: 'var(--border)',
|
background: statusFilter === f.key ? 'var(--honey)' : 'var(--bg-card)',
|
||||||
}}
|
color: statusFilter === f.key ? '#000' : 'var(--text-dim)',
|
||||||
>
|
fontWeight: statusFilter === f.key ? 600 : 400,
|
||||||
<option value="all">全部蜂型</option>
|
transition: 'all 0.15s',
|
||||||
<option value="worker">工蜂</option>
|
}}
|
||||||
<option value="queen">蜂王</option>
|
>
|
||||||
</select>
|
{f.label}
|
||||||
{gateways.length > 0 && (
|
</button>
|
||||||
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>
|
))}
|
||||||
共 {filtered.length} / {gateways.length} 个 Gateway
|
</div>
|
||||||
</span>
|
|
||||||
)}
|
{/* search + type filter */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Search
|
||||||
|
size={14}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: 10,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
placeholder="搜索 Gateway..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
height: 32,
|
||||||
|
padding: '0 10px',
|
||||||
|
borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">全部蜂型</option>
|
||||||
|
<option value="worker">工蜂</option>
|
||||||
|
<option value="queen">蜂王</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- table ---- */}
|
{/* ---- table ---- */}
|
||||||
<div className="mt-3">
|
<div>
|
||||||
{filtered.length === 0 && !loading ? (
|
{filtered.length === 0 && !loading ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-[var(--radius)] p-8 text-center"
|
className="rounded-[var(--radius)] p-8 text-center"
|
||||||
style={{ background: 'var(--bg-card)' }}
|
style={{ background: 'var(--bg-card)' }}
|
||||||
>
|
>
|
||||||
<p style={{ color: 'var(--text-dim)', fontSize: 14 }}>全部 Gateway 正常运行 🎉</p>
|
<p style={{ color: 'var(--text-dim)', fontSize: 14 }}>全部 Gateway 正常运行</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type FormEvent, useState } from 'react'
|
import { type FormEvent, useState } from 'react'
|
||||||
import { useNavigate, Link } from 'react-router-dom'
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
import { useAuth } from '../hooks/useAuth'
|
import { useAuth } from '../hooks/useAuth'
|
||||||
import Button from '../components/ui/Button'
|
|
||||||
import toast from '../components/ui/Toast'
|
import toast from '../components/ui/Toast'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -9,6 +9,7 @@ export default function LoginPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPwd, setShowPwd] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -35,33 +36,63 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-root)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className="p-8 rounded-[var(--radius)] w-[360px]"
|
style={{
|
||||||
style={{ background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)' }}
|
width: 400,
|
||||||
|
padding: 32,
|
||||||
|
borderRadius: 24,
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
boxShadow: 'var(--shadow-lg)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<h1 className="text-center" style={{ color: 'var(--honey)', fontSize: 20, fontWeight: 700 }}>
|
<div style={{ textAlign: 'center', marginBottom: 4 }}>
|
||||||
🐝 知微蜂巢
|
<div style={{ fontSize: 28, fontWeight: 700, color: '#fff' }}>
|
||||||
</h1>
|
🐝 知微蜂巢
|
||||||
<p className="text-center mt-1" style={{ color: 'var(--text-dim)', fontSize: 13 }}>
|
</div>
|
||||||
Hive Manage v3
|
<div style={{ fontSize: 14, color: 'var(--text-dim)', marginTop: 4 }}>
|
||||||
</p>
|
Hive Manage v3
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
className="mt-4 p-3 rounded-[var(--radius)] text-[13px] font-medium"
|
style={{
|
||||||
style={{ background: 'var(--bg-error)', color: 'var(--red)' }}
|
marginTop: 16,
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
background: 'var(--bg-error)',
|
||||||
|
color: 'var(--red)',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="mt-6 flex flex-col gap-4">
|
<form onSubmit={handleSubmit} style={{ marginTop: 24, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-dim)' }}>
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
用户名
|
用户名
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -71,39 +102,104 @@ export default function LoginPage() {
|
|||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full mt-1 px-3 py-[9px] rounded-[var(--radius-sm)] text-[13px] outline-none transition-colors"
|
|
||||||
style={{
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 42,
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
background: 'var(--bg-root)',
|
background: 'var(--bg-root)',
|
||||||
color: 'var(--text)',
|
color: 'var(--text)',
|
||||||
border: '1px solid var(--border)',
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
fontFamily: 'inherit',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[12px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-dim)' }}>
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#fff',
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
密码
|
密码
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div style={{ position: 'relative' }}>
|
||||||
type="password"
|
<input
|
||||||
value={password}
|
type={showPwd ? 'text' : 'password'}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
value={password}
|
||||||
placeholder="请输入密码"
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
placeholder="请输入密码"
|
||||||
className="w-full mt-1 px-3 py-[9px] rounded-[var(--radius-sm)] text-[13px] outline-none transition-colors"
|
required
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--bg-root)',
|
width: '100%',
|
||||||
color: 'var(--text)',
|
height: 42,
|
||||||
border: '1px solid var(--border)',
|
padding: '0 36px 0 12px',
|
||||||
}}
|
borderRadius: 8,
|
||||||
/>
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--bg-root)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: 13,
|
||||||
|
outline: 'none',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPwd(!showPwd)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: 10,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-muted)',
|
||||||
|
padding: 4,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPwd ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" variant="primary" loading={loading} className="w-full !py-[9px]">
|
<button
|
||||||
登入
|
type="submit"
|
||||||
</Button>
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 8,
|
||||||
|
border: 'none',
|
||||||
|
background: 'var(--honey)',
|
||||||
|
color: '#000',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: loading ? 0.6 : 1,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? '登录中...' : '登入'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Register link */}
|
{/* Register link */}
|
||||||
<p className="text-center mt-4 text-[13px]" style={{ color: 'var(--text-dim)' }}>
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 20,
|
||||||
|
fontSize: 13,
|
||||||
|
color: 'var(--text-dim)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
还没有账号?{' '}
|
还没有账号?{' '}
|
||||||
<Link to="/register" style={{ color: 'var(--honey)', fontWeight: 600 }}>
|
<Link to="/register" style={{ color: 'var(--honey)', fontWeight: 600 }}>
|
||||||
注册
|
注册
|
||||||
|
|||||||
+144
-66
@@ -1,83 +1,161 @@
|
|||||||
/* ===== 暗色模式(默认) ===== */
|
/* =============================================================
|
||||||
|
* Hive Manage v2 — Design Tokens (CSS Variables)
|
||||||
|
* 基于 UI-design-spec.md v2.1
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* 暗色模式(默认): 直接引入本文件
|
||||||
|
* 浅色模式: 在 <html> 上加 data-theme="light"
|
||||||
|
* 跟随系统: 用 @media (prefers-color-scheme: light) 覆盖
|
||||||
|
* ============================================================= */
|
||||||
|
|
||||||
|
/* === 暗色模式(默认) === */
|
||||||
:root {
|
:root {
|
||||||
--bg-root: #0f1117;
|
/* 背景层级 */
|
||||||
--bg-nav: #161822;
|
--bg-root: #0f1117;
|
||||||
--bg-card: #1a1d2e;
|
--bg-nav: #161822;
|
||||||
--bg-hover: #1e2134;
|
--bg-card: #1a1d2e;
|
||||||
|
--bg-hover: #1e2134;
|
||||||
--bg-elevated: #222639;
|
--bg-elevated: #222639;
|
||||||
--border: #2a2d3e;
|
|
||||||
--border-light: rgba(42, 45, 62, 0.5);
|
/* 边框 */
|
||||||
--honey: #f0b90b;
|
--border: #2a2d3e;
|
||||||
--honey-dim: #b8970e;
|
--border-light: rgba(42,45,62,0.5);
|
||||||
--honey-glow: rgba(240, 185, 11, 0.12);
|
|
||||||
--green: #00c853;
|
/* 品牌色 */
|
||||||
--red: #ff3d3d;
|
--honey: #f0b90b;
|
||||||
--yellow: #ffab00;
|
--honey-dim: #b8970e;
|
||||||
--blue: #448aff;
|
--honey-glow: rgba(240,185,11,0.12);
|
||||||
--orange: #ff6d00;
|
|
||||||
--text: #e0e0e0;
|
/* 功能色 */
|
||||||
--text-dim: #8b8d9a;
|
--green: #00c853;
|
||||||
--text-muted: #5a5d6a;
|
--red: #ff3d3d;
|
||||||
--bg-success: #0d2818;
|
--yellow: #ffab00;
|
||||||
--bg-error: #2d0f0f;
|
--blue: #448aff;
|
||||||
--bg-warn: #2d2000;
|
--orange: #ff6d00;
|
||||||
--bg-info: #0d1b2d;
|
|
||||||
--radius: 8px;
|
/* 文字 */
|
||||||
--radius-sm: 4px;
|
--text: #e0e0e0;
|
||||||
--radius-lg: 12px;
|
--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;
|
--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);
|
--shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
--shadow-lg: 0 12px 32px rgba(0,0,0,0.55);
|
||||||
--font-mono: "SF Mono", "Fira Code", Consolas, monospace;
|
--shadow-modal: 0 24px 64px rgba(0,0,0,0.65);
|
||||||
--header-h: 48px;
|
|
||||||
--sidebar-w: 220px;
|
/* 间距 */
|
||||||
|
--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"] {
|
[data-theme="light"] {
|
||||||
--bg-root: #f5f6fa;
|
--bg-root: #f5f6fa;
|
||||||
--bg-nav: #ffffff;
|
--bg-nav: #ffffff;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
--bg-hover: #edf0f5;
|
--bg-hover: #edf0f5;
|
||||||
--bg-elevated: #ffffff;
|
--bg-elevated: #ffffff;
|
||||||
--border: #e2e5ee;
|
|
||||||
--border-light: rgba(0, 0, 0, 0.06);
|
--border: #e2e5ee;
|
||||||
--honey: #e5a800;
|
--border-light: rgba(0,0,0,0.06);
|
||||||
--honey-dim: #c48f00;
|
|
||||||
--honey-glow: rgba(229, 168, 0, 0.08);
|
--honey: #e5a800;
|
||||||
--green: #008c3a;
|
--honey-dim: #c48f00;
|
||||||
--red: #d32f2f;
|
--honey-glow: rgba(229,168,0,0.08);
|
||||||
--yellow: #e65100;
|
|
||||||
--blue: #1565c0;
|
--green: #008c3a;
|
||||||
--orange: #d84315;
|
--red: #d32f2f;
|
||||||
--text: #1a1d2e;
|
--yellow: #e65100;
|
||||||
--text-dim: #555a6e;
|
--blue: #1565c0;
|
||||||
--text-muted: #8b8f9e;
|
--orange: #d84315;
|
||||||
--bg-success: #e8f5e9;
|
|
||||||
--bg-error: #fdecea;
|
--text: #1a1d2e;
|
||||||
--bg-warn: #fff3e0;
|
--text-dim: #555a6e;
|
||||||
--bg-info: #e3f2fd;
|
--text-muted: #8b8f9e;
|
||||||
--shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.1);
|
--bg-success: #e8f5e9;
|
||||||
--shadow-modal: 0 16px 48px rgba(0, 0, 0, 0.14);
|
--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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
html, body {
|
||||||
margin: 0;
|
font-family: var(--font-sans);
|
||||||
font-family: var(--font);
|
|
||||||
font-size: 13px;
|
|
||||||
background: var(--bg-root);
|
background: var(--bg-root);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 主题切换过渡 */
|
/* Backward compat aliases */
|
||||||
* {
|
:root { --font: var(--font-sans); --header-h: 48px; --sidebar-w: 220px; }
|
||||||
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user