mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-28 04:57:29 +08:00
add webui
This commit is contained in:
54
webui/src/components/Header.tsx
Normal file
54
webui/src/components/Header.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Terminal, Globe } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isGatewayOnline } = useAppContext();
|
||||
|
||||
const toggleLang = () => {
|
||||
const nextLang = i18n.language === 'en' ? 'zh' : 'en';
|
||||
i18n.changeLanguage(nextLang);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="h-16 border-b border-zinc-800 bg-zinc-900/50 flex items-center justify-between px-6 shrink-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-indigo-500 flex items-center justify-center shadow-lg shadow-indigo-500/20">
|
||||
<Terminal className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold text-xl tracking-tight">ClawGo</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2.5 bg-zinc-900 border border-zinc-800 px-3 py-1.5 rounded-lg">
|
||||
<span className="text-sm font-medium text-zinc-400">{t('gatewayStatus')}:</span>
|
||||
{isGatewayOnline ? (
|
||||
<div className="flex items-center gap-1.5 bg-emerald-500/10 text-emerald-400 px-2.5 py-0.5 rounded-md text-xs font-semibold border border-emerald-500/20">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.8)]" />
|
||||
{t('online')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 bg-red-500/10 text-red-400 px-2.5 py-0.5 rounded-md text-xs font-semibold border border-red-500/20">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.8)]" />
|
||||
{t('offline')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-zinc-800" />
|
||||
|
||||
<button
|
||||
onClick={toggleLang}
|
||||
className="flex items-center gap-2 text-sm font-medium text-zinc-400 hover:text-zinc-200 transition-colors bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 px-3 py-1.5 rounded-lg"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{i18n.language === 'en' ? '中文' : 'English'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
34
webui/src/components/Layout.tsx
Normal file
34
webui/src/components/Layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { Outlet, useLocation } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import Header from './Header';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-zinc-950 text-zinc-50 overflow-hidden font-sans">
|
||||
<Header />
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<Sidebar />
|
||||
<main className="flex-1 flex flex-col min-w-0 relative bg-zinc-950/50">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-0 overflow-y-auto"
|
||||
>
|
||||
<Outlet />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
24
webui/src/components/NavItem.tsx
Normal file
24
webui/src/components/NavItem.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = ({ icon, label, to }) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) => `w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-indigo-500/10 text-indigo-400'
|
||||
: 'text-zinc-400 hover:bg-zinc-800/50 hover:text-zinc-200'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export default NavItem;
|
||||
69
webui/src/components/RecursiveConfig.tsx
Normal file
69
webui/src/components/RecursiveConfig.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface RecursiveConfigProps {
|
||||
data: any;
|
||||
labels: Record<string, string>;
|
||||
path?: string;
|
||||
onChange: (path: string, val: any) => void;
|
||||
}
|
||||
|
||||
const RecursiveConfig: React.FC<RecursiveConfigProps> = ({ data, labels, path = '', onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (typeof data !== 'object' || data === null) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-10">
|
||||
{Object.entries(data).map(([key, value]) => {
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
const label = labels[key] || key.replace(/_/g, ' ');
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return (
|
||||
<div key={currentPath} className="space-y-6 col-span-full">
|
||||
<h3 className="text-sm font-semibold text-zinc-400 uppercase tracking-wider flex items-center gap-2">
|
||||
<span className="w-1.5 h-4 bg-indigo-500 rounded-full" />
|
||||
{label}
|
||||
</h3>
|
||||
<div className="pl-6 border-l border-zinc-800/50">
|
||||
<RecursiveConfig data={value} labels={labels} path={currentPath} onChange={onChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={currentPath} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-zinc-300 block capitalize">{label}</span>
|
||||
<span className="text-[10px] text-zinc-600 font-mono">{currentPath}</span>
|
||||
</div>
|
||||
{typeof value === 'boolean' ? (
|
||||
<label className="flex items-center gap-3 p-3 bg-zinc-950 border border-zinc-800 rounded-lg cursor-pointer hover:border-zinc-700 transition-colors group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={(e) => onChange(currentPath, e.target.checked)}
|
||||
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-zinc-950 bg-zinc-900"
|
||||
/>
|
||||
<span className="text-sm text-zinc-400 group-hover:text-zinc-300 transition-colors">
|
||||
{value ? (labels['enabled_true'] || t('enabled_true')) : (labels['enabled_false'] || t('enabled_false'))}
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<input
|
||||
type={typeof value === 'number' ? 'number' : 'text'}
|
||||
value={value === null || value === undefined ? '' : String(value)}
|
||||
onChange={(e) => onChange(currentPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2.5 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors font-mono text-zinc-300"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecursiveConfig;
|
||||
39
webui/src/components/Sidebar.tsx
Normal file
39
webui/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Globe, Zap } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppContext } from '../context/AppContext';
|
||||
import NavItem from './NavItem';
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { token, setToken } = useAppContext();
|
||||
|
||||
return (
|
||||
<aside className="w-64 border-r border-zinc-800 bg-zinc-900/30 flex flex-col shrink-0">
|
||||
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||
<NavItem icon={<LayoutDashboard className="w-5 h-5" />} label={t('dashboard')} to="/" />
|
||||
<NavItem icon={<MessageSquare className="w-5 h-5" />} label={t('chat')} to="/chat" />
|
||||
<NavItem icon={<Terminal className="w-5 h-5" />} label={t('logs')} to="/logs" />
|
||||
<NavItem icon={<Zap className="w-5 h-5" />} label={t('skills')} to="/skills" />
|
||||
<div className="h-4" />
|
||||
<div className="text-[10px] font-bold text-zinc-600 uppercase tracking-widest px-4 mb-2">System</div>
|
||||
<NavItem icon={<Settings className="w-5 h-5" />} label={t('config')} to="/config" />
|
||||
<NavItem icon={<Clock className="w-5 h-5" />} label={t('cronJobs')} to="/cron" />
|
||||
<NavItem icon={<Server className="w-5 h-5" />} label={t('nodes')} to="/nodes" />
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-zinc-800 bg-zinc-900/50">
|
||||
<div className="text-xs font-medium text-zinc-500 mb-2 uppercase tracking-wider px-1">{t('gatewayToken')}</div>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder={t('enterToken')}
|
||||
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
21
webui/src/components/StatCard.tsx
Normal file
21
webui/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const StatCard: React.FC<StatCardProps> = ({ title, value, icon }) => (
|
||||
<div className="bg-zinc-900/50 border border-zinc-800 rounded-2xl p-6 flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-zinc-800/50 flex items-center justify-center border border-zinc-700/50">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-zinc-400 text-sm font-medium mb-1">{title}</div>
|
||||
<div className="text-2xl font-semibold text-zinc-100">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default StatCard;
|
||||
Reference in New Issue
Block a user