Compare commits

...
3 Commits
Author SHA1 Message Date
enzo 0e393f265d fix: color tokens aligned to reference image pixel extraction 2026-07-22 22:28:07 +08:00
enzoandClaude ae04ffa9a0 fix: Gateway trend bars missing height — now render 48 visible bars
Bars had no `height` property, making them invisible despite the legend
showing correctly. Each bar now computes height proportional to anomaly
count (max 36px), with a 3px minimum for normal intervals.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 22:05:55 +08:00
enzoandClaude c79b1af392 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>
2026-07-22 22:03:55 +08:00
9 changed files with 1332 additions and 380 deletions
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
@@ -0,0 +1,154 @@
# Hive Manage v3 前端视觉重写 — 像素级对齐参考图
## 前置要求
- 所有 CSS 使用 `design-tokens.css` 中的变量(已在 `frontend/src/styles/tokens.css`)
- 禁止硬编码任何颜色值
- 间距必须是 4 的倍数
- 表格行高 44px,padding: 10px 14px
- 动画 ≤300ms,页面切换无动画
- 暗色模式下的阴影仅三层:悬浮/弹出/模态
## 1. 登录页 (参考 assets/ref-images/02-login.png)
### 布局
- 页面背景: var(--bg-root),纯暗色无纹理
- 登录卡片: 水平垂直居中,宽约 400px,大圆角(24px),var(--bg-card) 背景
- 卡片阴影: var(--shadow-lg)
### Logo
- 顶部居中: 🐝 蜜蜂图标 + "知微蜂巢" 白色粗体 28px
- 下方: "Hive Manage v3" 浅灰色 14px var(--text-dim)
### 输入框
- 标签: 白色小字,输入框上方
- 输入框: var(--bg-root) 背景,1px solid var(--border),圆角 8px,高 42px
- Placeholder: var(--text-muted),"请输入用户名"/"请输入密码"
- 密码框右侧有眼睛图标(显示/隐藏切换)
### 按钮
- "登入": 全宽,var(--honey) 背景,黑色粗体文字,高 48px,圆角 8px
- 下方 "还没有账号?注册" var(--text-dim) 文字,var(--honey) 链接
## 2. 侧边栏 (参考 01-dashboard-dark.png)
### 分组结构(从上到下)
**蜂群态势**
- 📊 蜂群总览 (active: var(--honey-glow) 背景 + var(--honey) 文字)
- 📡 Gateway 健康 [NEW Badge: 金色小标签]
- 🏥 Profile 健康分 [NEW Badge]
- 🤖 工蜂列表
**任务编排**
- 📋 看板追踪
- ⏰ Cron 管理 [红色计数 Badge]
- 🔔 事件管理 [红色计数 Badge]
**通信频道**
- 📶 频道配置
- 📡 频道监控 [NEW Badge]
**运维工具**
- 🔑 密钥管理
- 📁 文件浏览 [NEW Badge]
- 💻 Web 终端 [NEW Badge]
- 📜 蜂群日志 [NEW Badge]
- 🔌 MCP 接入管理 [NEW Badge]
- 📤 MCP 对外服务 [NEW Badge]
**知识库管理**
- 📚 事件档案管理 [NEW Badge]
**系统**
- 👥 用户管理
- 📋 审计日志
- ⚙ 系统设置
### 样式
- 分组标题: 11px uppercase,var(--text-muted),letter-spacing 0.5px,padding 8px 10px 4px
- NavItem: padding 8px 10px,border-radius 4px
- NavItem 默认: var(--text-dim)
- NavItem hover: var(--bg-hover) 背景,var(--text) 文字
- NavItem active: var(--honey-glow) 背景,var(--honey) 文字,font-weight 600
- Badge: 小圆角标签,红色 Badge 用于计数,金色 Badge 用于 NEW
## 3. Header (参考 01-dashboard-dark.png)
- 高度: 48px,var(--bg-nav) 背景,底边框 1px var(--border)
- 左侧: 🐝 Logo + 面包屑 "/ 蜂群总览" var(--text-dim)
- 搜索框: var(--bg-card) 背景,260px 宽,placeholder "搜索 Profile / 任务 / Cron / 事件..."
- 右侧: 状态标签(在线 12 / 运行中 3)+ 主题切换按钮 + 用户头像
## 4. Dashboard (参考 01-dashboard-dark.png)
### 统计卡片(4列网格)
- 在线 Gateway: 12(绿色 var(--green)), ↑ 绿色箭头 "2 vs 昨天"
- 离线 Gateway: 46(红色 var(--red)), ↓ 红色箭头 "1 vs 昨天"
- 运行中任务: 3(蓝色 var(--blue))
- 今日 Cron 失败: 1(红色,失败>3标红)
卡片: var(--bg-card) 背景,1px var(--border),padding 16px,圆角 8px
### 健康分布
- 横向堆叠柱状图: A(绿22) B(蓝18) C(黄10) D(红8)
- 柱高 8px,圆角 4px
- "查看全部 →" 链接
- D 级 Profile: 逗号分隔列出名称
### Gateway 异常趋势 (24h)
- 48 根迷你竖条(每30分钟),绿/红/橙三色
- 图例: 🟢 正常 🔴 心跳超时 🟠 飞书断连
### Kanban 概览
- 表格: 看板名 | RUNNING | READY
- 如: europe-briefing | S4 质检 | S5 编排
### Cron 最近执行
- 表格: Job | 状态(✅/❌/⏸) | 时间
- 如: 涉台早报 ✅ 07:15
### 最近异常事件
- 完整表格: 时间 | Profile | 类型(Badge) | 摘要 | 状态(Badge)
- 至少 3 行数据
## 5. Gateway 页面 (参考 04-gateway.png)
### 顶部
- 标题 "Gateway 健康监控"
- 统计: 共 N 个 · 在线 N · 离线 N · 异常 N
- 右上角: "最后更新 N 秒前"
### 筛选栏
- 状态筛选: 全部/在线🔵/离线🔴/异常🟡,选中态 var(--honey) 背景
- 右侧: 搜索框 + 蜂型筛选下拉
### 异常时间线 (24h)
- 横向轴线 + 彩色圆点标记事件
- 红=心跳超时,橙=飞书断连,蓝=重启
- 圆点下方标注时间+事件名
### 表格
- 列: Profile名 | 状态Badge | 掉线时长 | 今日重启 | 最新异常 | 操作
- 异常行: row-danger 样式
- 操作按钮: var(--honey) 边框 + var(--honey) 文字,深色背景
---
## 执行计划
1. 先读 `frontend/src/styles/tokens.css` 确认变量名
2. 重写 `LoginPage.tsx` 对齐登录参考图
3. 重写 `Sidebar.tsx` 对齐侧边栏参考图(分组+Badge)
4. 重写 `Header.tsx` 对齐 Header 参考图
5. 重写 `DashboardPage.tsx` 对齐 Dashboard 参考图
6. 重写 `GatewayPage.tsx` 对齐 Gateway 参考图
7. 每完成一个文件运行 `npx tsc --noEmit`
8. 全部完成后 `npm run build`
9. 提交
**质量红线**: 不允许出现以下情况——
- 硬编码颜色(必须用 var(--*))
- 间距不是 4 的倍数
- 表格行高不是 44px
- 动画超过 300ms
- 阴影超过三层
+58
View File
@@ -0,0 +1,58 @@
修复前端视觉质量问题。项目在 /home/enzo/projects/hive-manage-v3/frontend/。
## 问题清单
### 1. 全局:浅色背景
当前 Dashboard 显示白色/浅灰背景,应该是暗色主题。原因:`data-theme` 属性未正确初始化,导致 CSS 变量默认值被 Tailwind 覆盖。
修复:确保 `<html>` 元素初始化时设置 `data-theme="dark"`,且所有页面使用 CSS 变量而非硬编码颜色。
### 2. 侧边栏:导航项 typo
"Cron1" 应为 "Cron 管理"。
### 3. Dashboard 卡片:视觉粗糙
- 在线/离线 Gateway 卡片应该有颜色区分(绿色数值/红色数值),当前只有文字
- "vs 昨天" 的变化指示应该有 ↑/↓ 箭头
- Cron 失败 > 3 的卡片应标红
- 整体卡片间距和 padding 应符合设计规范(gap:14px, padding:16px)
### 4. 蜂群健康分布
柱状图样式简陋,应有:
- 四色柱子(A绿 B蓝 C黄 D红)
- 点击柱子跳转 /health 页面
- D 级 Profile 名称在下方列出
- "共 58 个 Profile" 标题
### 5. Gateway 异常趋势
- Timeline 组件时间轴应无 "R/F/H/OK" 文字,改用彩色圆点
- 每个点 Hover 显示 tooltip(时间和事件详情)
- 图例正确:●正常 ●心跳超时 ●飞书断连 ●重启
### 6. Kanban 概览
- 当前显示静态数据,应调用 GET /api/v1/dashboard/kanban-summary API
- 看板名 + RUNNING/READY 状态显示
### 7. Cron 最近执行
- 调用 GET /api/v1/dashboard/cron-recent API
- 状态正确显示:✅ 成功 / ❌ 失败 / ⏸ 暂停
- 时间显示精确到分钟
### 8. 侧边栏整体样式
- 背景色:var(--bg-nav) 暗色
- Section 标题:11px uppercase letter-spacing
- NavItem 激活态:honey-glow 背景 + honey 文字色
- 各导航项名称应与 PRD/原型一致
### 9. Header
- "在线 0 运行中 0 离线 0" 应为实时数据(从 usePolling 或 context 获取)
- 搜索框 placeholder "搜索 Profile / 任务 / Cron / 事件..."
- 主题切换按钮(🌙/☀️)正常工作
### 10. useTheme hook
确保:
- 初始默认 dark(根元素设置 data-theme="dark")
- localStorage 持久化
- 切换即时生效
---
先读取当前所有相关文件,然后逐个修复上述问题。每修复 2-3 个问题就运行 `npx tsc --noEmit` 确认编译通过。最后 `npm run build` 并提交。
+129 -56
View File
@@ -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<HTMLInputElement>) => {
const value = (e.target as HTMLInputElement).value.trim()
@@ -39,6 +42,7 @@ export default function Header({
<header
style={{
gridArea: 'header',
height: 'var(--header-h)',
background: 'var(--bg-nav)',
borderBottom: '1px solid var(--border)',
display: 'flex',
@@ -46,41 +50,66 @@ export default function Header({
padding: '0 20px',
gap: 16,
zIndex: 10,
height: 'var(--header-h)',
}}
>
{/* Logo */}
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--honey)', whiteSpace: 'nowrap' }}>
<span
style={{
fontWeight: 700,
fontSize: 14,
color: 'var(--honey)',
whiteSpace: 'nowrap',
}}
>
🐝 知微蜂巢
</span>
{/* Breadcrumb */}
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>
/ <span style={{ color: 'var(--text)' }}>{breadcrumb || '蜂群总览'}</span>
/{' '}
<span style={{ color: 'var(--text)' }}>
{breadcrumb || '蜂群总览'}
</span>
</span>
{/* Spacer */}
<div style={{ flex: 1 }} />
{/* Search */}
<input
className="global-search"
placeholder="🔍 搜索 Profile / 任务 / Cron / 事件..."
onKeyDown={handleSearchKeyDown}
style={{
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-sm)',
padding: '6px 12px',
color: 'var(--text)',
width: 260,
fontSize: 13,
outline: 'none',
fontFamily: 'inherit',
}}
onFocus={(e) => { e.currentTarget.style.borderColor = 'var(--honey)' }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--border)' }}
/>
<div style={{ position: 'relative' }}>
<Search
size={14}
style={{
position: 'absolute',
left: 10,
top: '50%',
transform: 'translateY(-50%)',
color: 'var(--text-muted)',
}}
/>
<input
placeholder="搜索 Profile / 任务 / Cron / 事件..."
onKeyDown={handleSearchKeyDown}
style={{
width: 260,
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 */}
<span
@@ -92,9 +121,13 @@ export default function Header({
fontSize: 12,
color: 'var(--text-dim)',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
🟢 {onlineCount} online
<span style={{ color: 'var(--green)' }}>●</span>
{onlineCount} online
</span>
<span
style={{
@@ -105,19 +138,36 @@ export default function Header({
fontSize: 12,
color: 'var(--text-dim)',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
🟡 {runningCount} running
<span style={{ color: 'var(--yellow)' }}>●</span>
{runningCount} running
</span>
{/* Theme toggle */}
<button
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' ? '切换浅色模式' : '切换深色模式'}
style={{ background: 'transparent', border: 'none', fontSize: 18, cursor: 'pointer', padding: 4, borderRadius: 'var(--radius-sm)', color: 'var(--text-dim)', lineHeight: 1 }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
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' ? '☀️' : '🌙'}
</button>
@@ -125,46 +175,69 @@ export default function Header({
{/* User avatar */}
<div
style={{ position: 'relative', cursor: 'pointer', flexShrink: 0 }}
className="group"
onMouseEnter={() => setMenuOpen(true)}
onMouseLeave={() => setMenuOpen(false)}
>
<div
style={{
width: 30, height: 30, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600,
background: 'var(--honey-glow)', color: 'var(--honey)',
width: 30,
height: 30,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 13,
fontWeight: 600,
background: 'var(--honey-glow)',
color: 'var(--honey)',
}}
title={user?.role ?? ''}
>
{initial}
</div>
<div
style={{
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') }}
{menuOpen && (
<div
style={{
width: '100%', textAlign: 'left', padding: '6px 12px',
fontSize: 13, border: 'none', background: 'transparent',
color: 'var(--red)', cursor: 'pointer', fontFamily: 'inherit',
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,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent' }}
>
退出登录
</button>
</div>
<button
onClick={() => {
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>
</header>
)
+7 -7
View File
@@ -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 (
<button
@@ -187,7 +187,7 @@ export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
width: '100%',
textAlign: 'left',
fontFamily: 'inherit',
transition: 'all .15s',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
if (!isActive) {
+359 -156
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowUp, ArrowDown } from 'lucide-react'
import Skeleton from '../components/ui/Skeleton'
import {
@@ -26,13 +27,6 @@ function timeAgo(iso: string): string {
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 {
time: string
profile: string
@@ -57,19 +51,49 @@ function StatCard({
label: string; value: string | number; valueColor?: string
change?: string; changeDirection?: 'up' | 'down'
}) {
const dirIcon = changeDirection === 'up' ? '↑' : '↓'
const dirColor = changeDirection === 'up' ? 'var(--green)' : 'var(--red)'
const iconColor = changeDirection === 'up' ? 'var(--green)' : 'var(--red)'
return (
<div 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 }}>
<div
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}
</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}
</div>
{change && (
<div style={{ fontSize: 12, marginTop: 4, color: changeDirection ? dirColor : 'var(--text-dim)' }}>
{changeDirection && <span>{dirIcon} </span>}
<div
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}
</div>
)}
@@ -77,7 +101,7 @@ function StatCard({
)
}
/* ---------- Health vertical bar chart ---------- */
/* ---------- Health stacked bar chart (horizontal) ---------- */
const GRADE_META = [
{ key: 'A', color: 'var(--green)' },
@@ -86,39 +110,58 @@ const GRADE_META = [
{ key: 'D', color: 'var(--red)' },
] 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) {
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 (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, height: 120, padding: '0 8px' }}>
{GRADE_META.map(function(meta) {
var val = dist[meta.key];
var pct = (val / maxVal) * 100;
return (
<div key={meta.key} onClick={function() { onBarClick(meta.key) }}
style={{
flex: 1, borderRadius: '4px 4px 0 0', textAlign: 'center', position: 'relative',
minWidth: 40, cursor: 'pointer', height: Math.max(pct, 4) + '%', backgroundColor: meta.color,
transition: 'opacity .15s',
}}
onMouseEnter={function(e) { e.currentTarget.style.opacity = '0.8' }}
onMouseLeave={function(e) { e.currentTarget.style.opacity = '1' }}
>
<span style={{ position: 'absolute', top: -18, left: '50%', transform: 'translateX(-50%)', fontSize: 12, fontWeight: 700, color: 'var(--text)' }}>
{val}
</span>
<span style={{ position: 'absolute', bottom: -22, left: '50%', transform: 'translateX(-50%)', fontSize: 11, color: 'var(--text-dim)' }}>
{meta.key}级
</span>
</div>
)
})}
<div>
<div
style={{
display: 'flex',
height: 8,
borderRadius: 4,
overflow: 'hidden',
marginBottom: 12,
}}
>
{GRADE_META.map((meta) => {
const val = dist[meta.key as keyof HealthDistribution] as number
if (val === 0) return null
const pct = total > 0 ? (val / total) * 100 : 0
return (
<div
key={meta.key}
onClick={() => onBarClick(meta.key)}
style={{
width: pct + '%',
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>
)
}
/* ---------- Gateway 48-bar timeline ---------- */
function barColor(item: GatewayTrendItem): string {
@@ -128,32 +171,59 @@ function barColor(item: GatewayTrendItem): string {
}
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 });
}
const maxIssues = Math.max(1, ...trend.map(
i => i.heartbeat_timeouts + i.feishu_disconnects + i.restarts,
))
return (
<div style={{ display: 'flex', gap: 2, height: 40, alignItems: 'flex-end', marginBottom: 12 }}>
{bars.map(function(b, i) {
return (
<div key={i} title={b.tooltip}
style={{
flex: 1, borderRadius: '2px 2px 0 0', minWidth: 3, cursor: 'pointer', position: 'relative',
backgroundColor: b.color,
}}
onMouseEnter={function(e) { e.currentTarget.style.opacity = '0.7' }}
onMouseLeave={function(e) { e.currentTarget.style.opacity = '1' }}
/>
)
})}
<div>
<div
style={{
display: 'flex',
gap: 2,
height: 40,
alignItems: 'flex-end',
marginBottom: 12,
}}
>
{trend.map((item, i) => {
const col = barColor(item)
const total = item.heartbeat_timeouts + item.feishu_disconnects + item.restarts
const pct = total / maxIssues
const height = total === 0 ? 3 : Math.max(3, Math.round(pct * 36))
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,
height,
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>
)
}
@@ -161,14 +231,23 @@ function GatewayTimeline({ trend }: { trend: GatewayTrendItem[] }) {
/* ---------- Badge helpers for events table ---------- */
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-warn': { background: 'rgba(255,171,0,.15)', color: 'var(--yellow)' },
'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 (
<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}
</span>
)
@@ -177,21 +256,21 @@ function BadgeSpan({ cls, children }: { cls: string; children: React.ReactNode }
/* ---------- main page ---------- */
export default function DashboardPage() {
var navigate = useNavigate()
const navigate = useNavigate()
var _a = useState<SummaryData | null>(null), summary = _a[0], setSummary = _a[1]
var _b = useState<HealthDistribution | null>(null), health = _b[0], setHealth = _b[1]
var _c = useState<GatewayTrendItem[]>([]), trend = _c[0], setTrend = _c[1]
var _d = useState<KanbanSummaryItem[]>([]), kanban = _d[0], setKanban = _d[1]
var _e = useState<CronRecentItem[]>([]), cron = _e[0], setCron = _e[1]
var _f = useState(true), loading = _f[0], setLoading = _f[1]
var _g = useState<string | null>(null), error = _g[0], setError = _g[1]
const [summary, setSummary] = useState<SummaryData | null>(null)
const [health, setHealth] = useState<HealthDistribution | null>(null)
const [trend, setTrend] = useState<GatewayTrendItem[]>([])
const [kanban, setKanban] = useState<KanbanSummaryItem[]>([])
const [cron, setCron] = useState<CronRecentItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
var fetchAll = useCallback(async function() {
const fetchAll = useCallback(async () => {
setLoading(true)
setError(null)
try {
var res = await Promise.all([
const res = await Promise.all([
getDashboardSummary(),
getHealthDistribution(),
getGatewayTrend(),
@@ -203,14 +282,14 @@ export default function DashboardPage() {
setTrend(res[2].data)
setKanban(res[3].data)
setCron(res[4].data)
} catch (e) {
} catch {
setError('加载仪表盘数据失败')
} finally {
setLoading(false)
}
}, [])
useEffect(function() { fetchAll() }, [fetchAll])
useEffect(() => { fetchAll() }, [fetchAll])
/* loading */
if (loading) {
@@ -231,9 +310,30 @@ export default function DashboardPage() {
return (
<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>
<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>
</div>
@@ -251,8 +351,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 (
<div>
@@ -260,28 +359,55 @@ export default function DashboardPage() {
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginBottom: 20 }}>{subtitle}</div>
{/* stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14, marginBottom: 20 }}>
<StatCard label='在线 Gateway' value={summary.online_count} valueColor='green' change={'↑ vs 昨天'} changeDirection='up' />
<StatCard label='离线 Gateway' value={summary.offline_count} valueColor='red' change={'↓ vs 昨天'} changeDirection='down' />
{/* stat cards: 4-column grid */}
<div
style={{
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='今日 Cron 失败' value={summary.cron_failures_today} valueColor='red' />
</div>
{/* health + trend */}
{/* health + trend: 3-column layout (2fr 1fr) */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginBottom: 20 }}>
{/* health distribution */}
<div 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)' }}>
<div
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 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>
</div>
<div style={{ padding: 16 }}>
{health ? (
<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 ? (
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
<span>⚠ {health.d_profiles.length} 个 D 级 Profile:</span>
@@ -296,77 +422,150 @@ export default function DashboardPage() {
</div>
{/* gateway trend */}
<div 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
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)
</div>
<div style={{ padding: 16 }}>
{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>
{/* kanban + cron */}
{/* kanban + cron: 2-column grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }}>
{/* kanban overview */}
<div 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
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 任务概览
</div>
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-dim)', marginBottom: 12 }}>
<span>看板</span>
<span>RUNNING</span>
<span>READY</span>
</div>
{kanban.length > 0 ? kanban.map(function(item) {
return (
<div key={item.board} onClick={function() { navigate('/kanban') }}
style={{ fontSize: 12, color: 'var(--text-dim)', padding: '4px 0', borderBottom: '1px solid var(--border-light)', cursor: 'pointer', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-dim)' }}>{item.board}</span>
<span style={{ color: 'var(--green)' }}>{item.running || '—'}</span>
<span style={{ color: 'var(--blue)', marginLeft: 40 }}>{item.ready || '—'}</span>
</div>
)
}) : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<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>
<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>
<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>
</tr>
</thead>
<tbody>
{kanban.length > 0 ? kanban.map((item) => (
<tr
key={item.board}
onClick={() => navigate('/kanban')}
style={{ cursor: 'pointer', height: 44 }}
>
<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>
{/* cron recent */}
<div 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
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 最近执行
</div>
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-dim)', marginBottom: 8 }}>
<span>Job</span>
<span>状态</span>
<span>时间</span>
</div>
{cron.length > 0 ? cron.map(function(item) {
return (
<div key={item.job} onClick={function() { navigate('/cron') }}
style={{ fontSize: 12, padding: '4px 0', borderBottom: '1px solid var(--border-light)', cursor: 'pointer' }}>
<span style={{ color: 'var(--text-dim)' }}>{item.job}</span>
<span style={{ float: 'right', marginRight: 80 }}>
{item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'}
</span>
<span style={{ float: 'right', color: 'var(--text-muted)' }}>{item.time || '—'}</span>
</div>
)
}) : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<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>
<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>
<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>
</tr>
</thead>
<tbody>
{cron.length > 0 ? cron.map((item) => (
<tr
key={item.job}
onClick={() => navigate('/cron')}
style={{ cursor: 'pointer', height: 44 }}
>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{item.job}</td>
<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>
{/* recent events table */}
<div 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
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 style={{ overflowX: 'auto' }}>
@@ -381,21 +580,25 @@ export default function DashboardPage() {
</tr>
</thead>
<tbody>
{MOCK_EVENTS.map(function(evt, i) {
return (
<tr key={i} style={evt.status === '待处理' ? { background: 'rgba(255,61,61,.06)' } : undefined}>
<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)', color: 'var(--blue)' }}>{evt.profile}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
<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)' }}>
<BadgeSpan cls={evt.statusClass}>{evt.status}</BadgeSpan>
</td>
</tr>
)
})}
{MOCK_EVENTS.map((evt, i) => (
<tr
key={i}
style={{
height: 44,
...(evt.status === '待处理' ? { background: 'rgba(255,61,61,.06)' } : {}),
}}
>
<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)', color: 'var(--blue)' }}>{evt.profile}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
<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)' }}>
<BadgeSpan cls={evt.statusClass}>{evt.status}</BadgeSpan>
</td>
</tr>
))}
</tbody>
</table>
</div>
+201 -60
View File
@@ -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<GatewayEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
const [statusFilter, setStatusFilter] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<string>('all')
const [operating, setOperating] = useState<string | null>(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 ? (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
loading={operating === gw.name}
<div style={{ display: 'flex', gap: 4 }}>
<button
disabled={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'restart' })}
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} />
</Button>
</button>
{gw.status === 'online' ? (
<Button
size="sm"
variant="ghost"
loading={operating === gw.name}
<button
disabled={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'stop' })}
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} />
</Button>
</button>
) : (
<Button
size="sm"
variant="ghost"
loading={operating === gw.name}
<button
disabled={operating === gw.name}
onClick={() => setModalAction({ profile: gw.name, action: 'start' })}
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} />
</Button>
</button>
)}
</div>
) : 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 (
<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="table" count={5} className="mt-4" />
</div>
@@ -195,13 +251,27 @@ export default function GatewayPage() {
if (error && gateways.length === 0) {
return (
<div>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 管理</h2>
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway 健康监控</h2>
<div
className="mt-4 p-5 rounded-[var(--radius)] text-center"
style={{ background: 'var(--bg-card)' }}
>
<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>
)
@@ -209,12 +279,32 @@ export default function GatewayPage() {
return (
<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 ---- */}
{timelineEvents.length > 0 && (
<div
className="mt-4 rounded-[var(--radius)] p-4"
className="mb-4 rounded-[var(--radius)] p-4"
style={{ background: 'var(--bg-card)' }}
>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
@@ -232,51 +322,102 @@ export default function GatewayPage() {
)}
{/* ---- filter bar ---- */}
<div className="mt-4 flex items-center gap-3 flex-wrap">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1.5 text-[13px] rounded-[var(--radius-sm)] border cursor-pointer"
style={{
background: 'var(--bg-card)',
color: 'var(--text)',
borderColor: 'var(--border)',
}}
>
<option value="all">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="warn">异常</option>
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-1.5 text-[13px] rounded-[var(--radius-sm)] border cursor-pointer"
style={{
background: 'var(--bg-card)',
color: 'var(--text)',
borderColor: 'var(--border)',
}}
>
<option value="all">全部蜂型</option>
<option value="worker">工蜂</option>
<option value="queen">蜂王</option>
</select>
{gateways.length > 0 && (
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>
共 {filtered.length} / {gateways.length} 个 Gateway
</span>
)}
<div
className="mb-3"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 12,
}}
>
{/* status filter buttons */}
<div style={{ display: 'flex', gap: 4 }}>
{statusFilters.map((f) => (
<button
key={f.key}
onClick={() => setStatusFilter(f.key)}
style={{
padding: '6px 14px',
borderRadius: 'var(--radius-sm)',
border: 'none',
fontSize: 13,
cursor: 'pointer',
fontFamily: 'inherit',
background: statusFilter === f.key ? 'var(--honey)' : 'var(--bg-card)',
color: statusFilter === f.key ? '#000' : 'var(--text-dim)',
fontWeight: statusFilter === f.key ? 600 : 400,
transition: 'all 0.15s',
}}
>
{f.label}
</button>
))}
</div>
{/* 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>
{/* ---- table ---- */}
<div className="mt-3">
<div>
{filtered.length === 0 && !loading ? (
<div
className="rounded-[var(--radius)] p-8 text-center"
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>
) : (
<Table
+130 -34
View File
@@ -1,7 +1,7 @@
import { type FormEvent, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Eye, EyeOff } from 'lucide-react'
import { useAuth } from '../hooks/useAuth'
import Button from '../components/ui/Button'
import toast from '../components/ui/Toast'
export default function LoginPage() {
@@ -9,6 +9,7 @@ export default function LoginPage() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPwd, setShowPwd] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
@@ -35,33 +36,63 @@ export default function LoginPage() {
}
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
className="p-8 rounded-[var(--radius)] w-[360px]"
style={{ background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)' }}
style={{
width: 400,
padding: 32,
borderRadius: 24,
background: 'var(--bg-card)',
boxShadow: 'var(--shadow-lg)',
}}
>
{/* Logo */}
<h1 className="text-center" style={{ color: 'var(--honey)', fontSize: 20, fontWeight: 700 }}>
🐝 知微蜂巢
</h1>
<p className="text-center mt-1" style={{ color: 'var(--text-dim)', fontSize: 13 }}>
Hive Manage v3
</p>
<div style={{ textAlign: 'center', marginBottom: 4 }}>
<div style={{ fontSize: 28, fontWeight: 700, color: '#fff' }}>
🐝 知微蜂巢
</div>
<div style={{ fontSize: 14, color: 'var(--text-dim)', marginTop: 4 }}>
Hive Manage v3
</div>
</div>
{/* Error */}
{error && (
<div
className="mt-4 p-3 rounded-[var(--radius)] text-[13px] font-medium"
style={{ background: 'var(--bg-error)', color: 'var(--red)' }}
style={{
marginTop: 16,
padding: '8px 12px',
borderRadius: 'var(--radius)',
background: 'var(--bg-error)',
color: 'var(--red)',
fontSize: 13,
fontWeight: 500,
}}
>
{error}
</div>
)}
{/* 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>
<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>
<input
@@ -71,39 +102,104 @@ export default function LoginPage() {
placeholder="请输入用户名"
required
autoFocus
className="w-full mt-1 px-3 py-[9px] rounded-[var(--radius-sm)] text-[13px] outline-none transition-colors"
style={{
width: '100%',
height: 42,
padding: '0 12px',
borderRadius: 8,
border: '1px solid var(--border)',
background: 'var(--bg-root)',
color: 'var(--text)',
border: '1px solid var(--border)',
fontSize: 13,
outline: 'none',
fontFamily: 'inherit',
}}
/>
</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>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
required
className="w-full mt-1 px-3 py-[9px] rounded-[var(--radius-sm)] text-[13px] outline-none transition-colors"
style={{
background: 'var(--bg-root)',
color: 'var(--text)',
border: '1px solid var(--border)',
}}
/>
<div style={{ position: 'relative' }}>
<input
type={showPwd ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
required
style={{
width: '100%',
height: 42,
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>
<Button type="submit" variant="primary" loading={loading} className="w-full !py-[9px]">
登入
</Button>
<button
type="submit"
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>
{/* 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 }}>
注册
+145 -67
View File
@@ -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 {
--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;
/* 背景层级 — 对齐参考图实际色值 */
--bg-root: #03050a;
--bg-nav: #060810;
--bg-card: #0b0e17;
--bg-hover: #111522;
--bg-elevated: #151b28;
/* 边框 */
--border: #2a2d3e;
--border-light: rgba(42,45,62,0.5);
/* 品牌色 */
--honey: #e9ab01;
--honey-dim: #c48f00;
--honey-glow: rgba(233,171,1,0.12);
/* 功能色 */
--green: #23ad3f;
--red: #e04343;
--yellow: #e9ab01;
--blue: #3b82f6;
--orange: #f59e0b;
/* 文字 */
--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; }