Compare commits

...
6 Commits
Author SHA1 Message Date
enzoandClaude 7441546804 feat: layout components (Sidebar, Header, MobileNav, TabBar, AppLayout) with routing
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:33:42 +08:00
enzoandClaude bd3d0147f5 feat: shared UI component library (11 components)
Add Button, Card, Table, Badge, Modal, Drawer, Toast, Skeleton,
Toggle, Timeline, SearchBox — all using CSS variables from tokens.css
with className prop extension and exported Props interfaces.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:31:14 +08:00
enzoandClaude fbee37635b feat: CSS variable dual-theme system (dark default + light)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:28:23 +08:00
enzoandClaude 4762376d0a feat: Vite + React 19 + Tailwind + TypeScript scaffold
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:28:11 +08:00
enzoandClaude 5a69cea3ae feat: Alembic setup, User and AuditLog models with migration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:25:53 +08:00
enzoandClaude 83112ed36d feat: FastAPI skeleton with config, database, health endpoint
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:23:59 +08:00
49 changed files with 4998 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+46
View File
@@ -0,0 +1,46 @@
from logging.config import fileConfig
from alembic import context
from app.config import settings
from app.database import Base
from app.models import * # noqa: F401,F403
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = settings.database_url
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations():
from app.database import engine
async with engine.begin() as conn:
await conn.run_sync(do_run_migrations)
def run_migrations_online():
import asyncio
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,58 @@
"""create users and audit_logs tables
Revision ID: ab0c56ae4242
Revises:
Create Date: 2026-07-22 16:25:22.698619
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ab0c56ae4242'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'users',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('username', sa.String(50), unique=True, nullable=False, index=True),
sa.Column('email', sa.String(255), unique=True, nullable=False),
sa.Column('hashed_password', sa.String(255), nullable=False),
sa.Column(
'role',
sa.Enum('owner', 'operator', 'viewer', name='userrole'),
nullable=False,
server_default='viewer',
),
sa.Column('is_active', sa.Boolean, nullable=False, server_default=sa.true()),
sa.Column('is_approved', sa.Boolean, nullable=False, server_default=sa.false()),
sa.Column('login_attempts', sa.Integer, nullable=False, server_default='0'),
sa.Column('locked_until', sa.DateTime, nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
)
op.create_table(
'audit_logs',
sa.Column('id', sa.String(36), primary_key=True),
sa.Column('user_id', sa.String(36), nullable=False, index=True),
sa.Column('username', sa.String(50), nullable=False),
sa.Column('action', sa.String(100), nullable=False, index=True),
sa.Column('target', sa.String(255), nullable=False),
sa.Column('detail', sa.Text, nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('audit_logs')
op.drop_table('users')
op.execute('DROP TYPE IF EXISTS userrole')
View File
+18
View File
@@ -0,0 +1,18 @@
"""应用配置 — 从环境变量 / .env 读取。"""
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql+asyncpg://hive_admin:changeme@localhost:5432/hive_manage_v3"
jwt_secret: str = "change-me"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 1440
hermes_cli_path: str = "/home/enzo/.local/bin/hermes"
hermes_profiles_root: str = "/home/enzo/.hermes/profiles"
admin_email: str = "admin@zhiwei.local"
admin_password: str = "admin123"
model_config = {"env_file": ".env"}
settings = Settings()
+17
View File
@@ -0,0 +1,17 @@
"""异步 SQLAlchemy 引擎 + session 工厂。"""
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session() as session:
yield session
+34
View File
@@ -0,0 +1,34 @@
"""FastAPI 应用入口。"""
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
await engine.dispose()
app = FastAPI(
title="Hive Manage v3",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/v1/health")
async def health_check():
return {"status": "ok"}
+1
View File
@@ -0,0 +1 @@
+20
View File
@@ -0,0 +1,20 @@
"""审计日志 ORM 模型。"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
username: Mapped[str] = mapped_column(String(50), nullable=False)
action: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
target: Mapped[str] = mapped_column(String(255), nullable=False)
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+30
View File
@@ -0,0 +1,30 @@
"""用户 ORM 模型。"""
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
import enum
from app.database import Base
class UserRole(str, enum.Enum):
OWNER = "owner"
OPERATOR = "operator"
VIEWER = "viewer"
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.VIEWER, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_approved: Mapped[bool] = mapped_column(Boolean, default=False)
login_attempts: Mapped[int] = mapped_column(default=0)
locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hive Manage v3</title>
</head>
<body class="bg-[var(--bg-root)] text-[var(--text)]">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3096
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "hive-manage-v3-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0",
"react-dom": "^19.0",
"react-router-dom": "^7.0",
"axios": "^1.7",
"lucide-react": "^0.460"
},
"devDependencies": {
"@types/react": "^19.0",
"@types/react-dom": "^19.0",
"@vitejs/plugin-react": "^4.3",
"autoprefixer": "^10.4",
"postcss": "^8.4",
"tailwindcss": "^3.4",
"typescript": "^5.6",
"vite": "^6.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+57
View File
@@ -0,0 +1,57 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Skeleton from './components/ui/Skeleton'
import AppLayout from './components/layout/AppLayout'
import LoginPage from './pages/LoginPage'
import NotFoundPage from './pages/NotFoundPage'
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const GatewayPage = lazy(() => import('./pages/GatewayPage'))
const HealthPage = lazy(() => import('./pages/HealthPage'))
const KanbanPage = lazy(() => import('./pages/KanbanPage'))
const CronPage = lazy(() => import('./pages/CronPage'))
const KeysPage = lazy(() => import('./pages/KeysPage'))
const EventArchivePage = lazy(() => import('./pages/EventArchivePage'))
const UserManagementPage = lazy(() => import('./pages/UserManagementPage'))
const AuditLogPage = lazy(() => import('./pages/AuditLogPage'))
function Loading() {
return (
<div className="p-5">
<Skeleton variant="card" count={2} />
</div>
)
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/404" element={<NotFoundPage />} />
<Route
path="/"
element={
<AppLayout>
<Suspense fallback={<Loading />}>
<Routes>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="gateway" element={<GatewayPage />} />
<Route path="health" element={<HealthPage />} />
<Route path="kanban" element={<KanbanPage />} />
<Route path="cron" element={<CronPage />} />
<Route path="keys" element={<KeysPage />} />
<Route path="events" element={<EventArchivePage />} />
<Route path="users" element={<UserManagementPage />} />
<Route path="audit" element={<AuditLogPage />} />
</Routes>
</Suspense>
</AppLayout>
}
/>
<Route path="*" element={<Navigate to="/404" replace />} />
</Routes>
</BrowserRouter>
)
}
@@ -0,0 +1,138 @@
import { useState, type ReactNode } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTheme } from '../../hooks/useTheme'
import Sidebar from './Sidebar'
import Header from './Header'
import MobileNav from './MobileNav'
import TabBar from './TabBar'
function useMediaQuery(query: string) {
const [matches, setMatches] = useState(() => {
if (typeof window !== 'undefined') {
return window.matchMedia(query).matches
}
return false
})
if (typeof window !== 'undefined') {
const mql = window.matchMedia(query)
mql.addEventListener('change', (e) => setMatches(e.matches))
}
return matches
}
export interface AppLayoutProps {
children: ReactNode
breadcrumb?: string
onlineCount?: number
runningCount?: number
onSearch?: (query: string) => any
}
export default function AppLayout({
children,
breadcrumb,
onlineCount = 0,
runningCount = 0,
onSearch,
}: AppLayoutProps) {
const { theme, toggle } = useTheme()
const navigate = useNavigate()
const location = useLocation()
const isMobile = useMediaQuery('(max-width: 767px)')
const isTablet = useMediaQuery('(min-width: 768px) and (max-width: 1023px)')
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const handleNavigate = (path: string) => {
navigate(path)
}
const sidebarWidth = isTablet ? 180 : 220
if (isMobile) {
return (
<div className="min-h-screen flex flex-col" style={{ background: 'var(--bg-root)' }}>
{/* Mobile header */}
<header
className="flex items-center justify-between px-4 shrink-0"
style={{
height: 'var(--header-h)',
background: 'var(--bg-nav)',
borderBottom: '1px solid var(--border)',
}}
>
<div className="flex items-center gap-2">
<button
onClick={() => setMobileNavOpen(true)}
className="cursor-pointer bg-transparent border-none text-[20px] p-1"
style={{ color: 'var(--text)' }}
>
☰
</button>
<span style={{ color: 'var(--honey)', fontWeight: 700, fontSize: 14 }}>知微蜂巢</span>
</div>
<button
onClick={toggle}
className="cursor-pointer bg-transparent border-none text-[18px] leading-none p-1"
title={theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</header>
{/* Mobile content */}
<main className="flex-1 overflow-y-auto p-3 pb-[64px]">{children}</main>
{/* Mobile tab bar */}
<TabBar activePath={location.pathname} />
{/* Mobile nav drawer */}
<MobileNav
open={mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
activePath={location.pathname}
onNavigate={handleNavigate}
/>
</div>
)
}
return (
<div
className="min-h-screen"
style={{
display: 'grid',
gridTemplateColumns: `${sidebarWidth}px 1fr`,
gridTemplateRows: 'var(--header-h) 1fr',
gridTemplateAreas: '"sidebar header" "sidebar main"',
background: 'var(--bg-root)',
}}
>
{/* Sidebar */}
<div style={{ gridArea: 'sidebar' }}>
<Sidebar activePath={location.pathname} onNavigate={handleNavigate} />
</div>
{/* Header */}
<div style={{ gridArea: 'header' }}>
<Header
breadcrumb={breadcrumb}
onlineCount={onlineCount}
runningCount={runningCount}
onSearch={onSearch}
theme={theme}
onToggleTheme={toggle}
/>
</div>
{/* Main */}
<main
className="overflow-y-auto p-5"
style={{ gridArea: 'main', background: 'var(--bg-root)' }}
>
{children}
</main>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useNavigate } from 'react-router-dom'
import Badge from '../ui/Badge'
import SearchBox from '../ui/SearchBox'
export interface HeaderProps {
breadcrumb?: string
onlineCount?: number
runningCount?: number
onSearch?: (query: string) => any
theme: string
onToggleTheme: () => void
}
export default function Header({
breadcrumb,
onlineCount = 0,
runningCount = 0,
onSearch,
theme,
onToggleTheme,
}: HeaderProps) {
const navigate = useNavigate()
return (
<header
className="flex items-center gap-3 px-4 shrink-0"
style={{
height: 'var(--header-h)',
background: 'var(--bg-nav)',
borderBottom: '1px solid var(--border)',
}}
>
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-[13px] min-w-0" style={{ color: 'var(--text-dim)' }}>
{breadcrumb && (
<>
<span
className="cursor-pointer hover:text-[var(--text)] transition-colors"
onClick={() => navigate('/dashboard')}
>
首页
</span>
<span style={{ color: 'var(--text-muted)' }}>/</span>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{breadcrumb}</span>
</>
)}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Search */}
{onSearch && (
<SearchBox onSearch={onSearch} placeholder="搜索功能、任务、日志..." />
)}
{/* Status badges */}
<Badge variant="success">在线 {onlineCount}</Badge>
<Badge variant="honey">运行中 {runningCount}</Badge>
{/* 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' ? '切换浅色模式' : '切换深色模式'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
{/* User avatar */}
<div
className="w-[30px] h-[30px] rounded-full flex items-center justify-center text-[13px] font-semibold cursor-pointer shrink-0"
style={{ background: 'var(--honey-glow)', color: 'var(--honey)' }}
title="管理员"
>
A
</div>
</header>
)
}
@@ -0,0 +1,75 @@
import { useEffect, useCallback, useState } from 'react'
import { createPortal } from 'react-dom'
import Sidebar from './Sidebar'
export interface MobileNavProps {
open: boolean
onClose: () => void
activePath: string
onNavigate: (path: string) => void
}
export default function MobileNav({ open, onClose, activePath, onNavigate }: MobileNavProps) {
const [mounted, setMounted] = useState(false)
const [visible, setVisible] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
requestAnimationFrame(() => setVisible(true))
} else {
setVisible(false)
const timer = setTimeout(() => setMounted(false), 250)
return () => clearTimeout(timer)
}
}, [open])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose],
)
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, handleKeyDown])
const handleNavigate = (path: string) => {
onNavigate(path)
onClose()
}
if (!mounted) return null
return createPortal(
<div className="fixed inset-0 z-[9999]">
{/* Overlay */}
<div
className={`absolute inset-0 transition-opacity duration-250 ${
visible ? 'opacity-100' : 'opacity-0'
}`}
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={onClose}
/>
{/* Drawer */}
<div
className={`absolute top-0 left-0 h-full transition-transform duration-[250ms] ease-in-out`}
style={{
width: 280,
transform: visible ? 'translateX(0)' : 'translateX(-100%)',
}}
>
<Sidebar activePath={activePath} onNavigate={handleNavigate} />
</div>
</div>,
document.body,
)
}
+123
View File
@@ -0,0 +1,123 @@
import { type ReactNode } from 'react'
import Badge from '../ui/Badge'
interface NavItem {
label: string
icon: string
path: string
badge?: number
}
interface NavSection {
title: string
items: NavItem[]
}
const sections: NavSection[] = [
{
title: '蜂群态势',
items: [
{ label: '蜂群总览', icon: '📊', path: '/dashboard' },
{ label: 'Gateway', icon: '📡', path: '/gateway' },
{ label: '健康分', icon: '🏥', path: '/health' },
{ label: '工蜂列表', icon: '🤖', path: '/workers' },
],
},
{
title: '任务编排',
items: [
{ label: '看板追踪', icon: '📋', path: '/kanban' },
{ label: 'Cron', icon: '⏰', path: '/cron' },
{ label: '事件管理', icon: '🔔', path: '/events' },
],
},
{
title: '通信频道',
items: [{ label: '频道配置', icon: '📶', path: '/channels' }],
},
{
title: '运维工具',
items: [
{ label: '密钥', icon: '🔑', path: '/keys' },
{ label: '文件', icon: '📁', path: '/files' },
{ label: '终端', icon: '💻', path: '/terminal' },
{ label: '日志', icon: '📜', path: '/logs' },
{ label: 'MCP接入', icon: '🔌', path: '/mcp-in' },
{ label: 'MCP对外', icon: '📤', path: '/mcp-out' },
],
},
{
title: '知识库管理',
items: [{ label: '事件档案', icon: '📚', path: '/archive' }],
},
{
title: '系统',
items: [{ label: '设置', icon: '⚙', path: '/settings' }],
},
]
export interface SidebarProps {
activePath: string
onNavigate: (path: string) => void
}
export default function Sidebar({ activePath, onNavigate }: SidebarProps) {
return (
<nav className="flex flex-col h-full overflow-y-auto" style={{ background: 'var(--bg-nav)', borderRight: '1px solid var(--border)' }}>
{/* Logo */}
<div
className="flex items-center gap-2 shrink-0 cursor-pointer px-5"
style={{ height: 'var(--header-h)' }}
onClick={() => onNavigate('/dashboard')}
>
<span style={{ fontSize: 20 }}>🐝</span>
<span style={{ color: 'var(--honey)', fontWeight: 700, fontSize: 15, letterSpacing: 0.3 }}>
知微蜂巢
</span>
</div>
{/* Nav sections */}
<div className="flex-1 overflow-y-auto px-3 pb-4">
{sections.map(section => (
<div key={section.title} className="mt-4">
<div
className="px-2 mb-1 text-[11px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-muted)' }}
>
{section.title}
</div>
{section.items.map(item => {
const isActive = activePath === item.path
return (
<div
key={item.path}
className="flex items-center justify-between rounded-[var(--radius-sm)] cursor-pointer px-2 py-[7px] text-[13px] transition-colors duration-100"
style={{
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
fontWeight: isActive ? 600 : 400,
background: isActive ? 'var(--honey-glow)' : undefined,
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.background = 'transparent'
}}
onClick={() => onNavigate(item.path)}
>
<span>
<span style={{ marginRight: 8 }}>{item.icon}</span>
{item.label}
</span>
{item.badge != null && item.badge > 0 && (
<Badge variant="danger">{item.badge}</Badge>
)}
</div>
)
})}
</div>
))}
</div>
</nav>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useNavigate } from 'react-router-dom'
const tabs = [
{ label: '总览', icon: '📊', path: '/dashboard' },
{ label: 'Gateway', icon: '📡', path: '/gateway' },
{ label: 'Kanban', icon: '📋', path: '/kanban' },
{ label: 'Cron', icon: '⏰', path: '/cron' },
]
export interface TabBarProps {
activePath: string
}
export default function TabBar({ activePath }: TabBarProps) {
const navigate = useNavigate()
return (
<div
className="fixed bottom-0 left-0 right-0 flex items-center z-50"
style={{
height: 56,
background: 'var(--bg-nav)',
borderTop: '1px solid var(--border)',
}}
>
{tabs.map(tab => {
const isActive = activePath === tab.path
return (
<button
key={tab.path}
onClick={() => navigate(tab.path)}
className="flex-1 flex flex-col items-center justify-center gap-[2px] cursor-pointer bg-transparent border-none transition-colors duration-100"
style={{
color: isActive ? 'var(--honey)' : 'var(--text-dim)',
fontWeight: isActive ? 600 : 400,
}}
>
<span style={{ fontSize: 20 }}>{tab.icon}</span>
<span style={{ fontSize: 11 }}>{tab.label}</span>
</button>
)
})}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react'
export interface BadgeProps {
variant?: 'success' | 'danger' | 'warning' | 'info' | 'honey' | 'outline'
children: ReactNode
className?: string
}
const variantStyles: Record<string, { bg: string; color: string; border?: string }> = {
success: { bg: 'rgba(0, 200, 83, 0.15)', color: 'var(--green)' },
danger: { bg: 'rgba(255, 61, 61, 0.15)', color: 'var(--red)' },
warning: { bg: 'rgba(255, 171, 0, 0.15)', color: 'var(--yellow)' },
info: { bg: 'rgba(68, 138, 255, 0.15)', color: 'var(--blue)' },
honey: { bg: 'rgba(240, 185, 11, 0.15)', color: 'var(--honey)' },
outline: { bg: 'transparent', color: 'var(--text-dim)', border: '1px solid var(--border)' },
}
export default function Badge({ variant = 'outline', children, className = '' }: BadgeProps) {
const s = variantStyles[variant]
return (
<span
className={`inline-flex items-center px-[8px] py-[2px] rounded-[10px] text-[11px] font-semibold ${className}`}
style={{
backgroundColor: s.bg,
color: s.color,
border: s.border ?? 'none',
}}
>
{children}
</span>
)
}
+57
View File
@@ -0,0 +1,57 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'danger' | 'outline' | 'ghost'
size?: 'sm' | 'md'
loading?: boolean
children: ReactNode
}
export default function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
children,
onClick,
className = '',
type = 'button',
...rest
}: ButtonProps) {
const base =
'inline-flex items-center justify-center rounded-lg font-semibold cursor-pointer border transition-all duration-150'
const variantStyles: Record<string, string> = {
primary:
'bg-[var(--honey)] text-black border-[var(--honey)] font-semibold hover:brightness-110',
danger:
'bg-transparent text-[var(--red)] border-[var(--red)] hover:bg-[var(--red)] hover:text-white',
outline:
'bg-transparent text-[var(--text-dim)] border-[var(--border)] hover:bg-[var(--bg-hover)]',
ghost:
'bg-transparent text-[var(--text-dim)] border-transparent hover:bg-[var(--bg-hover)]',
}
const sizeStyles: Record<string, string> = {
sm: 'px-[10px] py-[4px] text-[12px]',
md: 'px-[14px] py-[7px] text-[13px]',
}
const dimClass =
disabled || loading ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
className={`${base} ${variantStyles[variant]} ${sizeStyles[size]} ${dimClass} ${className}`}
{...rest}
>
{loading && (
<span className="absolute inline-block w-[14px] h-[14px] border-2 border-transparent border-t-current rounded-full animate-spin" />
)}
<span className={loading ? 'text-transparent' : ''}>{children}</span>
</button>
)
}
+64
View File
@@ -0,0 +1,64 @@
export interface CardProps {
label: string
value: string | number
valueColor?: 'green' | 'red' | 'yellow' | 'blue'
change?: string
changeDirection?: 'up' | 'down' | 'neutral'
onClick?: () => void
className?: string
}
const valueColorMap: Record<string, string> = {
green: 'var(--green)',
red: 'var(--red)',
yellow: 'var(--yellow)',
blue: 'var(--blue)',
}
const directionIcon: Record<string, string> = {
up: '↑',
down: '↓',
neutral: '→',
}
const directionColor: Record<string, string> = {
up: 'var(--green)',
down: 'var(--red)',
neutral: 'var(--text-dim)',
}
export default function Card({
label,
value,
valueColor,
change,
changeDirection,
onClick,
className = '',
}: CardProps) {
return (
<div
onClick={onClick}
className={`bg-[var(--bg-card)] rounded-[var(--radius)] p-4 ${onClick ? 'cursor-pointer hover:bg-[var(--bg-hover)]' : ''} transition-colors duration-150 ${className}`}
>
<div className="text-[12px] uppercase tracking-wider text-[var(--text-dim)]">
{label}
</div>
<div
className="text-[28px] font-bold leading-tight mt-1"
style={{ color: valueColor ? valueColorMap[valueColor] : undefined }}
>
{value}
</div>
{change !== undefined && changeDirection && (
<div
className="text-[12px] mt-1 flex items-center gap-1"
style={{ color: directionColor[changeDirection] }}
>
<span>{directionIcon[changeDirection]}</span>
<span>{change}</span>
</div>
)}
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useCallback, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
export interface DrawerProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
footer?: ReactNode
width?: string | number
className?: string
}
export default function Drawer({
open,
onClose,
title,
children,
footer,
width = 480,
className = '',
}: DrawerProps) {
const [mounted, setMounted] = useState(false)
const [visible, setVisible] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
requestAnimationFrame(() => setVisible(true))
} else {
setVisible(false)
const timer = setTimeout(() => setMounted(false), 250)
return () => clearTimeout(timer)
}
}, [open])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose],
)
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, handleKeyDown])
if (!mounted) return null
return createPortal(
<div className="fixed inset-0 z-[9999]">
<div
className={`absolute inset-0 transition-opacity duration-250 ${
visible ? 'opacity-100' : 'opacity-0'
}`}
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
/>
<div
className={`absolute top-0 right-0 h-full bg-[var(--bg-nav)] shadow-[var(--shadow-lg)] flex flex-col transition-transform duration-[250ms] ease-in-out ${className}`}
style={{
width: typeof width === 'number' ? `${width}px` : width,
transform: visible ? 'translateX(0)' : 'translateX(100%)',
}}
>
{title && (
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)] shrink-0">
<h2 className="text-[15px] font-semibold text-[var(--text)]">{title}</h2>
<button
onClick={onClose}
className="text-[var(--text-dim)] hover:text-[var(--text)] text-[18px] leading-none cursor-pointer bg-transparent border-none"
>
✕
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer && (
<div className="px-5 py-4 border-t border-[var(--border)] shrink-0">
{footer}
</div>
)}
</div>
</div>,
document.body,
)
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import Button from './Button'
export interface ModalProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
footer?: ReactNode
confirmText?: string
onConfirm?: () => void
confirmDanger?: boolean
width?: string | number
className?: string
}
export default function Modal({
open,
onClose,
title,
children,
footer,
confirmText,
onConfirm,
confirmDanger,
width = 500,
className = '',
}: ModalProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose],
)
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, handleKeyDown])
if (!open) return null
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className={`bg-[var(--bg-nav)] rounded-[8px] shadow-[var(--shadow-modal)] w-full mx-4 flex flex-col max-h-[85vh] ${className}`}
style={{ maxWidth: typeof width === 'number' ? `${width}px` : width }}
onClick={(e) => e.stopPropagation()}
>
{title && (
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
<h2 className="text-[15px] font-semibold text-[var(--text)]">{title}</h2>
<button
onClick={onClose}
className="text-[var(--text-dim)] hover:text-[var(--text)] text-[18px] leading-none cursor-pointer bg-transparent border-none"
>
✕
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{(footer || onConfirm) && (
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-[var(--border)]">
{footer || (
<>
<Button variant="ghost" onClick={onClose}>
取消
</Button>
<Button
variant={confirmDanger ? 'danger' : 'primary'}
onClick={onConfirm}
>
{confirmText || '确认'}
</Button>
</>
)}
</div>
)}
</div>
</div>,
document.body,
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useRef, useEffect, useCallback, type ReactNode } from "react"
export interface SearchGroup {
label: string
items: { value: string; label: ReactNode }[]
}
export interface SearchBoxProps {
onSearch: (query: string) => SearchGroup[] | Promise<SearchGroup[]>
placeholder?: string
debounceMs?: number
className?: string
}
export default function SearchBox({
onSearch,
placeholder = "搜索...",
debounceMs = 300,
className = "",
}: SearchBoxProps) {
const [query, setQuery] = useState("")
const [groups, setGroups] = useState<SearchGroup[]>([])
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const flatItems = groups.flatMap((g) => g.items)
const doSearch = useCallback(
async (q: string) => {
if (!q.trim()) {
setGroups([])
setOpen(false)
return
}
setLoading(true)
try {
const result = await onSearch(q)
setGroups(result)
setOpen(result.length > 0 && result.some((g) => g.items.length > 0))
setActiveIdx(-1)
} finally {
setLoading(false)
}
},
[onSearch],
)
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => doSearch(query), debounceMs)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
}, [query, debounceMs, doSearch])
useEffect(() => {
if (!open) setActiveIdx(-1)
}, [open])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
inputRef.current &&
!inputRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open || flatItems.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIdx((prev) => (prev < flatItems.length - 1 ? prev + 1 : 0))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIdx((prev) => (prev > 0 ? prev - 1 : flatItems.length - 1))
} else if (e.key === "Enter" && activeIdx >= 0) {
e.preventDefault()
setQuery(String(flatItems[activeIdx].label))
setOpen(false)
inputRef.current?.blur()
} else if (e.key === "Escape") {
setOpen(false)
inputRef.current?.blur()
}
}
let cursor = 0
return (
<div className={`relative ${className}`} style={{ width: "420px", maxWidth: "100%" }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => { if (groups.length > 0) setOpen(true) }}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full h-[36px] px-3 text-[13px] rounded-lg border transition-colors duration-150 outline-none bg-[var(--bg-card)] text-[var(--text)] placeholder:text-[var(--text-muted)]"
style={{ borderColor: "var(--border)" }}
onFocusCapture={(e) => {
;(e.target as HTMLInputElement).style.borderColor = "var(--honey)"
}}
onBlurCapture={(e) => {
;(e.target as HTMLInputElement).style.borderColor = "var(--border)"
}}
/>
{loading && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 w-[14px] h-[14px] border-2 border-[var(--border)] border-t-[var(--honey)] rounded-full animate-spin" />
)}
{open && !loading && (
<div
ref={panelRef}
className="absolute top-full left-0 right-0 mt-1 rounded-lg shadow-[var(--shadow-lg)] z-50 max-h-[320px] overflow-y-auto"
style={{ backgroundColor: "var(--bg-elevated)" }}
>
{groups.map((group, gi) => {
const startIdx = cursor
cursor += group.items.length
return (
<div key={gi}>
<div className="px-3 py-2 text-[11px] font-semibold uppercase text-[var(--text-dim)]">
{group.label}
</div>
{group.items.map((item, ii) => {
const idx = startIdx + ii
return (
<div
key={item.value}
className={`px-3 py-[7px] text-[13px] cursor-pointer transition-colors duration-100 ${
idx === activeIdx ? "bg-[var(--bg-hover)]" : ""
}`}
style={{ color: "var(--text)" }}
onMouseEnter={() => setActiveIdx(idx)}
onMouseDown={() => {
setQuery(String(item.label))
setOpen(false)
}}
>
{item.label}
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { ReactNode } from "react"
const styleId = "ui-skeleton-keyframes"
if (typeof document !== "undefined" && !document.getElementById(styleId)) {
const style = document.createElement("style")
style.id = styleId
style.textContent = `@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }`
document.head.appendChild(style)
}
export interface SkeletonProps {
variant?: "card" | "table" | "text"
count?: number
className?: string
}
function Bar({ className = "" }: { className?: string }) {
return (
<div
className={`relative overflow-hidden rounded-[var(--radius-sm)] bg-[var(--bg-hover)] ${className}`}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 50%, transparent 100%)",
backgroundSize: "200% 100%",
animation: "shimmer 1500ms infinite",
}}
/>
</div>
)
}
function CardSkeleton() {
return (
<div className="bg-[var(--bg-card)] rounded-[var(--radius)] p-4 space-y-3">
<Bar className="h-3 w-1/3" />
<Bar className="h-8 w-1/2" />
<Bar className="h-3 w-1/4" />
</div>
)
}
function TableSkeleton({ rows }: { rows: number }) {
return (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<Bar key={i} className="h-5 w-full" />
))}
</div>
)
}
function TextSkeleton({ count }: { count: number }) {
return (
<div className="space-y-2">
{Array.from({ length: count }).map((_, i) => (
<Bar key={i} className={`h-3 ${i % 2 === 0 ? "w-3/4" : "w-1/2"}`} />
))}
</div>
)
}
export default function Skeleton({
variant = "text",
count = 3,
className = "",
}: SkeletonProps) {
if (variant === "card")
return (
<div className={`space-y-4 ${className}`}>
{Array.from({ length: count }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
)
if (variant === "table") return <TableSkeleton rows={count} />
return <TextSkeleton count={count} />
}
+84
View File
@@ -0,0 +1,84 @@
import type { ReactNode } from 'react'
export interface TableColumn {
key: string
label: string
width?: string | number
}
export interface TableProps {
columns: TableColumn[]
data: Record<string, ReactNode>[]
rowKey: string
onRowClick?: (record: Record<string, ReactNode>) => void
rowClassName?: (record: Record<string, ReactNode>) => string
emptyText?: string
className?: string
}
export default function Table({
columns,
data,
rowKey,
onRowClick,
rowClassName,
emptyText = '暂无数据',
className = '',
}: TableProps) {
return (
<div
className={`w-full overflow-auto rounded-[var(--radius)] border border-[var(--border)] ${className}`}
>
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
className="sticky top-0 z-10 bg-[var(--bg-nav)] text-[12px] uppercase text-[var(--text-dim)] font-semibold text-left px-4 h-[44px] whitespace-nowrap"
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="text-center text-[var(--text-dim)] py-8"
>
{emptyText}
</td>
</tr>
) : (
data.map((record) => {
const extra = rowClassName?.(record) ?? ''
return (
<tr
key={String(record[rowKey])}
onClick={() => onRowClick?.(record)}
className={`h-[44px] border-t border-[var(--border)] transition-colors duration-100 ${
onRowClick ? 'cursor-pointer' : ''
} hover:bg-[var(--bg-hover)] ${
extra.includes('row-danger')
? 'bg-[var(--bg-error)] hover:bg-[var(--bg-error)]'
: ''
}`}
>
{columns.map((col) => (
<td key={col.key} className="px-4">
{record[col.key]}
</td>
))}
</tr>
)
})
)}
</tbody>
</table>
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import type { ReactNode } from "react"
export interface TimelineEvent {
time: string
type: string
label: ReactNode
color?: string
}
export interface TimelineProps {
events: TimelineEvent[]
className?: string
}
const defaultColors: Record<string, string> = {
green: "var(--green)",
red: "var(--red)",
orange: "var(--orange)",
blue: "var(--blue)",
}
export default function Timeline({ events, className = "" }: TimelineProps) {
return (
<div className={`flex items-center gap-0 ${className}`}>
{events.map((evt, i) => {
const dotColor = evt.color || defaultColors[evt.type] || "var(--text-dim)"
const isLast = i === events.length - 1
return (
<div key={i} className="flex items-center flex-1 min-w-0">
<div className="flex flex-col items-center">
<div
className="w-[8px] h-[8px] rounded-full transition-transform duration-150 hover:-translate-y-[2px] group relative"
style={{ backgroundColor: dotColor }}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-[11px] whitespace-nowrap rounded-[var(--radius-sm)] opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10"
style={{ backgroundColor: "var(--bg-elevated)", color: "var(--text)" }}>
{evt.time}
</div>
</div>
<div className="text-[11px] text-[var(--text-dim)] mt-1">{evt.label}</div>
</div>
{!isLast && (
<div
className="flex-1 h-[2px] mx-1 self-start mt-[3px]"
style={{ backgroundColor: "var(--border)" }}
/>
)}
</div>
)
})}
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useState, useEffect, useCallback, type ReactNode } from "react"
import { createRoot } from "react-dom/client"
type ToastType = "success" | "error" | "warn" | "info"
interface ToastItem {
id: number
type: ToastType
message: string
}
type Listener = (item: ToastItem) => void
let emit: Listener | null = null
let seq = 0
function ToastStack() {
const [list, setList] = useState<ToastItem[]>([])
useEffect(() => {
emit = (item: ToastItem) => {
setList((prev) => {
const next = [...prev, item]
return next.length > 3 ? next.slice(-3) : next
})
if (item.type === "success") {
setTimeout(() => dismiss(item.id), 3000)
} else if (item.type === "error") {
setTimeout(() => dismiss(item.id), 5000)
}
}
return () => { emit = null }
}, [])
const dismiss = useCallback((id: number) => {
setList((prev) => prev.filter((t) => t.id !== id))
}, [])
if (list.length === 0) return null
return (
<div
className="fixed top-4 left-1/2 -translate-x-1/2 z-[99999] flex flex-col gap-2 w-full pointer-events-none"
style={{ maxWidth: "480px" }}
>
{list.map((item) => {
const bgMap: Record<ToastType, string> = {
success: "var(--bg-success)",
error: "var(--bg-error)",
warn: "var(--bg-warn)",
info: "var(--bg-info)",
}
const colorMap: Record<ToastType, string> = {
success: "var(--green)",
error: "var(--red)",
warn: "var(--yellow)",
info: "var(--blue)",
}
return (
<div
key={item.id}
className="pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-[var(--radius)] shadow-[var(--shadow)] text-[13px] font-medium transition-all duration-250 animate-in"
style={{
backgroundColor: bgMap[item.type],
color: colorMap[item.type],
}}
>
<span className="flex-1">{item.message}</span>
<button
onClick={() => dismiss(item.id)}
className="bg-transparent border-none cursor-pointer text-inherit opacity-60 hover:opacity-100 text-[14px] leading-none p-0"
>
✕
</button>
</div>
)
})}
</div>
)
}
let mounted = false
function ensureMounted() {
if (mounted) return
mounted = true
const el = document.createElement("div")
el.id = "ui-toast-root"
document.body.appendChild(el)
createRoot(el).render(<ToastStack />)
}
function toast(type: ToastType, message: string) {
ensureMounted()
const id = ++seq
const tryEmit = () => {
if (emit) {
emit({ id, type, message })
} else {
requestAnimationFrame(() => tryEmit())
}
}
tryEmit()
}
export default {
success: (msg: string) => toast("success", msg),
error: (msg: string) => toast("error", msg),
warn: (msg: string) => toast("warn", msg),
info: (msg: string) => toast("info", msg),
}
+44
View File
@@ -0,0 +1,44 @@
export interface ToggleProps {
checked: boolean
onChange: (checked: boolean) => void
label?: string
disabled?: boolean
className?: string
}
export default function Toggle({
checked,
onChange,
label,
disabled = false,
className = "",
}: ToggleProps) {
return (
<label
className={`inline-flex items-center gap-[8px] text-[13px] ${
disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"
} ${className}`}
>
<span
role="checkbox"
aria-checked={checked}
tabIndex={0}
onClick={() => !disabled && onChange(!checked)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !disabled) {
e.preventDefault()
onChange(!checked)
}
}}
className="relative inline-block w-[44px] h-[24px] rounded-[12px] transition-colors duration-200"
style={{ backgroundColor: checked ? "var(--honey)" : "var(--bg-hover)" }}
>
<span
className="absolute top-[2px] left-[2px] w-[20px] h-[20px] rounded-full bg-white transition-transform duration-200"
style={{ transform: checked ? "translateX(20px)" : "translateX(0)" }}
/>
</span>
{label && <span className="text-[var(--text)] select-none">{label}</span>}
</label>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react'
type Theme = 'dark' | 'light' | 'system'
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('hive-theme') as Theme) || 'system'
})
useEffect(() => {
const root = document.documentElement
if (theme === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
root.setAttribute('data-theme', prefersDark ? 'dark' : 'light')
} else {
root.setAttribute('data-theme', theme)
}
localStorage.setItem('hive-theme', theme)
}, [theme])
const toggle = () => setTheme(t => (t === 'dark' ? 'light' : 'dark'))
return { theme, setTheme, toggle }
}
+8
View File
@@ -0,0 +1,8 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import './styles/tokens.css'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><App /></React.StrictMode>
)
+3
View File
@@ -0,0 +1,3 @@
export default function AuditLogPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>审计日志</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>操作审计记录</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function CronPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Cron</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Cron 任务管理</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function DashboardPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Dashboard 仪表板</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function EventArchivePage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>事件档案</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>事件归档查询</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function GatewayPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>Gateway</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Gateway 管理</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function HealthPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>系统健康评分</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function KanbanPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>看板追踪</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Kanban 任务看板</p></div>
}
+3
View File
@@ -0,0 +1,3 @@
export default function KeysPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>密钥管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>API 密钥管理</p></div>
}
+15
View File
@@ -0,0 +1,15 @@
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
<div
className="p-8 rounded-[var(--radius)] w-[360px]"
style={{ background: 'var(--bg-card)', boxShadow: 'var(--shadow-lg)' }}
>
<h1 className="text-center mb-6" style={{ color: 'var(--honey)', fontSize: 20, fontWeight: 700 }}>
🐝 知微蜂巢
</h1>
<p className="text-center" style={{ color: 'var(--text-dim)', fontSize: 13 }}>登录页面(待实现)</p>
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-root)' }}>
<div className="text-center">
<h1 style={{ color: 'var(--text-muted)', fontSize: 48, fontWeight: 800 }}>404</h1>
<p style={{ color: 'var(--text-dim)', marginTop: 8 }}>页面不存在</p>
</div>
</div>
)
}
@@ -0,0 +1,3 @@
export default function UserManagementPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>用户管理</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>用户管理与权限</p></div>
}
+83
View File
@@ -0,0 +1,83 @@
/* ===== 暗色模式(默认) ===== */
: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;
--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;
}
/* ===== 浅色模式 ===== */
[data-theme="light"] {
--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);
}
/* ===== 全局基础 ===== */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font);
font-size: 13px;
background: var(--bg-root);
color: var(--text);
}
/* 主题切换过渡 */
* {
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
+14
View File
@@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss'
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors: {
honey: { DEFAULT: '#f0b90b', dim: '#b8970e' },
},
},
},
plugins: [],
} satisfies Config
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://127.0.0.1:8000',
},
},
})