Compare commits
6
Commits
2e1aa251d2
...
7441546804
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7441546804 | ||
|
|
bd3d0147f5 | ||
|
|
fbee37635b | ||
|
|
4762376d0a | ||
|
|
5a69cea3ae | ||
|
|
83112ed36d |
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
Generated
+3096
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user