Add console controls and service carousel

This commit is contained in:
Haitao Pan 2026-06-09 15:15:51 +08:00
parent 52ef20db0f
commit 79ebbd455c
2 changed files with 405 additions and 50 deletions

View File

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import ReactDOM from 'react-dom/client';
import './styles.css';
@ -89,6 +89,8 @@ function App() {
const [terminalExpanded, setTerminalExpanded] = useState(false);
const [terminalCollapsed, setTerminalCollapsed] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [language, setLanguage] = useState<'en' | 'zh'>('en');
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
fetch('http://127.0.0.1:8788/services')
@ -107,6 +109,63 @@ function App() {
const currentServices = services ?? mockServices;
const selected = tabs.find((tab) => tab.id === selectedTab);
const labels = language === 'zh'
? {
product: 'XWorkspace',
workspace: '工作空间',
collapse: '收起',
expand: '展开',
connected: '已连接',
agentsRunning: '个 Agent 运行中',
vaultReady: 'Vault 就绪',
homepageTitle: 'AI Workspace 控制面板',
homepageSubtitle: '在一个工作空间里统一组织 Runtime、Gateway 和本地 AI 服务。',
workspaceReady: '工作空间就绪',
activity: '服务活动',
coreServices: '核心服务',
serviceCards: '服务卡片',
today: '今天',
newTab: '新标签',
terminal: '终端',
maximize: '最大化',
restore: '还原',
themeLight: '浅色',
themeDark: '深色',
lang: '中/EN',
languageLabel: '语言',
themeLabel: '主题',
}
: {
product: 'XWorkspace',
workspace: 'Workspace',
collapse: 'Collapse',
expand: 'Expand',
connected: 'Connected',
agentsRunning: 'Agents Running',
vaultReady: 'Vault Ready',
homepageTitle: 'AI Workspace Control Plane',
homepageSubtitle: 'Runtime, gateway and local AI services are organized in one workspace.',
workspaceReady: 'Workspace Ready',
activity: 'Service Activity',
coreServices: 'Core Services',
serviceCards: 'Service Cards',
today: 'Today',
newTab: 'New Tab',
terminal: 'Terminal',
maximize: 'Maximize',
restore: 'Restore',
themeLight: 'Light',
themeDark: 'Dark',
lang: 'EN/中',
languageLabel: 'Language',
themeLabel: 'Theme',
};
const breadcrumbItems = [
labels.product,
labels.workspace,
selected && selected.id !== 'workspace' ? selected.label : null,
].filter(Boolean) as string[];
const summary = useMemo(() => {
const runningServices = currentServices.filter((service) => service.state === 'Running').length;
@ -137,11 +196,11 @@ function App() {
};
return (
<div className={sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell'}>
<div className={[sidebarCollapsed ? 'app-shell sidebar-collapsed' : 'app-shell', theme === 'dark' ? 'theme-dark' : ''].join(' ')}>
<aside className="sidebar">
<div className="brand">
<span className="brand-x">X</span>
<strong>XWorkspace</strong>
<strong>{labels.product}</strong>
</div>
<nav className="side-nav" aria-label="XWorkspace navigation">
@ -165,22 +224,42 @@ function App() {
))}
</nav>
<button className="collapse-button" type="button" onClick={() => setSidebarCollapsed((value) => !value)}>
<Icon name={sidebarCollapsed ? 'menu' : 'arrow-left'} />
<span>Collapse</span>
<div className="sidebar-tools">
<button className="sidebar-tool-button" type="button" onClick={() => setLanguage((value) => (value === 'en' ? 'zh' : 'en'))}>
<span>{labels.languageLabel}</span>
<strong>{labels.lang}</strong>
</button>
<button className="sidebar-tool-button" type="button" onClick={() => setTheme((value) => (value === 'light' ? 'dark' : 'light'))}>
<span>{labels.themeLabel}</span>
<strong>{theme === 'light' ? labels.themeDark : labels.themeLight}</strong>
</button>
</div>
<button className="collapse-button" type="button" aria-label={sidebarCollapsed ? labels.expand : labels.collapse} onClick={() => setSidebarCollapsed((value) => !value)}>
<Icon name={sidebarCollapsed ? 'chevrons-right' : 'chevrons-left'} />
</button>
</aside>
<main className="workspace">
<header className="topbar">
<button className="menu-button" type="button" aria-label="Toggle sidebar" onClick={() => setSidebarCollapsed((value) => !value)}>
<Icon name="menu" />
</button>
<div className="topbar-left">
<button className="menu-button" type="button" aria-label="Toggle sidebar" onClick={() => setSidebarCollapsed((value) => !value)}>
<Icon name="menu" />
</button>
<nav className="breadcrumb" aria-label="Breadcrumb">
{breadcrumbItems.map((item, index) => (
<React.Fragment key={`${item}-${index}`}>
{index > 0 ? <span className="breadcrumb-separator">/</span> : null}
<span className={index === breadcrumbItems.length - 1 ? 'breadcrumb-current' : ''}>{item}</span>
</React.Fragment>
))}
</nav>
</div>
<div className="status-strip">
<StatusItem label="Asia/Shanghai" icon="globe" />
<StatusItem label="Connected" icon="wifi" good />
<StatusItem label={`${summary.runningAgents} Agents Running`} icon="bot" good />
<StatusItem label="Vault Ready" icon="shield" good />
<StatusItem label={labels.connected} icon="wifi" good />
<StatusItem label={language === 'zh' ? `${summary.runningAgents} ${labels.agentsRunning}` : `${summary.runningAgents} ${labels.agentsRunning}`} icon="bot" good />
<StatusItem label={labels.vaultReady} icon="shield" good />
<strong>10:30</strong>
<button className="round-button" type="button" aria-label="Notifications">
<Icon name="bell" />
@ -228,10 +307,11 @@ function App() {
{selected?.kind === 'embed' && selected.id !== 'workspace' ? (
<EmbedWorkspace tab={selected} />
) : (
<WorkspaceHome services={currentServices} summary={summary} onOpenHome={() => setSelectedTab('workspace')} />
<WorkspaceHome labels={labels} services={currentServices} summary={summary} onOpenHome={() => setSelectedTab('workspace')} />
)}
<TerminalDrawer
labels={labels}
collapsed={terminalCollapsed}
expanded={terminalExpanded}
onCollapse={() => setTerminalCollapsed((value) => !value)}
@ -243,31 +323,50 @@ function App() {
}
function WorkspaceHome({
labels,
services,
summary,
onOpenHome,
}: {
labels: {
homepageTitle: string;
homepageSubtitle: string;
workspaceReady: string;
activity: string;
serviceCards: string;
coreServices: string;
today: string;
};
services: Service[];
summary: { runningServices: number; runningAgents: number; runningTasks: number };
onOpenHome: () => void;
}) {
const cardsRef = useRef<HTMLDivElement | null>(null);
const scrollCards = (direction: 'left' | 'right') => {
const viewport = cardsRef.current;
if (!viewport) return;
const distance = Math.max(280, Math.floor(viewport.clientWidth * 0.72));
viewport.scrollBy({ left: direction === 'left' ? -distance : distance, behavior: 'smooth' });
};
return (
<div className="workspace-body">
<section className="console-board">
<div className="command-panel">
<div className="board-heading">
<div>
<h1>AI Workspace Control Plane</h1>
<p>Runtime, gateway and local AI services are organized in one workspace.</p>
<h1>{labels.homepageTitle}</h1>
<p>{labels.homepageSubtitle}</p>
</div>
<span className="healthy-chip"><span /> Workspace Ready</span>
<span className="healthy-chip"><span /> {labels.workspaceReady}</span>
</div>
<div className="activity-card">
<div className="activity-head">
<h2>Service Activity</h2>
<h2>{labels.activity}</h2>
<div className="range-tabs" aria-label="Service activity range">
{['Today', '7d', '2w', '1m'].map((range, index) => <span className={index === 1 ? 'active' : ''} key={range}>{range}</span>)}
{[labels.today, '7d', '2w', '1m'].map((range, index) => <span className={index === 1 ? 'active' : ''} key={range}>{range}</span>)}
</div>
</div>
<div className="service-chart" aria-hidden="true">
@ -280,21 +379,48 @@ function WorkspaceHome({
</div>
</div>
<a className="service-cards homepage-link" href="#workspace" onClick={(event) => {
event.preventDefault();
onOpenHome();
}}>
{services.slice(0, 4).map((service, index) => (
<article className={index === 1 ? 'service-card tilted' : 'service-card'} key={service.name}>
<Icon name={service.name.toLowerCase().includes('vault') ? 'shield' : service.name.toLowerCase().includes('lite') ? 'chart' : service.name.toLowerCase().includes('bridge') ? 'bridge' : 'claw'} />
<span>{service.name}</span>
<strong>{service.state}</strong>
</article>
))}
</a>
<section className="service-carousel">
<div className="carousel-head">
<h2>{labels.serviceCards}</h2>
<div className="carousel-actions">
<button type="button" aria-label="Scroll service cards left" onClick={() => scrollCards('left')}>
<Icon name="chevron-left" />
</button>
<button type="button" aria-label="Scroll service cards right" onClick={() => scrollCards('right')}>
<Icon name="chevron-right" />
</button>
</div>
</div>
<div
className="service-cards-scroll"
ref={cardsRef}
onWheel={(event) => {
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
event.currentTarget.scrollBy({ left: event.deltaY, behavior: 'auto' });
}}
>
<a className="service-cards homepage-link" href="#workspace" onClick={(event) => {
event.preventDefault();
onOpenHome();
}}>
{services.map((service, index) => (
<article className={index === 1 ? 'service-card tilted' : 'service-card'} key={service.name}>
<Icon name={service.name.toLowerCase().includes('vault') ? 'shield' : service.name.toLowerCase().includes('lite') ? 'chart' : service.name.toLowerCase().includes('bridge') ? 'bridge' : 'claw'} />
<span>{service.name}</span>
<strong>{service.state}</strong>
</article>
))}
<article className="service-card ghost-card">
<Icon name="cube" />
<span>Future Probe</span>
<strong>Reserved</strong>
</article>
</a>
</div>
</section>
<section className="service-summary panel">
<div className="panel-head">
<h2>Core Services</h2>
<h2>{labels.coreServices}</h2>
</div>
<div className="service-list compact">
{services.map((service) => (
@ -331,11 +457,20 @@ function EmbedWorkspace({ tab }: { tab: Tab }) {
}
function TerminalDrawer({
labels,
collapsed,
expanded,
onCollapse,
onToggle,
}: {
labels: {
terminal: string;
newTab: string;
maximize: string;
restore: string;
collapse: string;
expand: string;
};
collapsed: boolean;
expanded: boolean;
onCollapse: () => void;
@ -346,12 +481,12 @@ function TerminalDrawer({
<div className="terminal-head">
<div>
<Icon name="terminal" />
<strong>Terminal</strong>
<strong>{labels.terminal}</strong>
</div>
<div className="terminal-actions">
<a href="http://127.0.0.1:7681" target="_blank" rel="noreferrer">New Tab</a>
<button type="button" onClick={onCollapse}>{collapsed ? 'Expand' : 'Collapse'}</button>
<button type="button" onClick={onToggle}>{expanded ? 'Restore' : 'Maximize'}</button>
<a href="http://127.0.0.1:7681" target="_blank" rel="noreferrer">{labels.newTab}</a>
<button type="button" onClick={onCollapse}>{collapsed ? labels.expand : labels.collapse}</button>
<button type="button" onClick={onToggle}>{expanded ? labels.restore : labels.maximize}</button>
<button type="button" aria-label="Terminal menu"></button>
</div>
</div>
@ -417,7 +552,14 @@ function Icon({ name }: { name: string }) {
wifi: <path d="M4 9a12 12 0 0 1 16 0M7 12a7.5 7.5 0 0 1 10 0M10 15a3 3 0 0 1 4 0M12 19h.01" />,
bell: <path d="M18 16H6l1.4-2V10a4.6 4.6 0 0 1 9.2 0v4zM10 19h4" />,
user: <path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4 21a8 8 0 0 1 16 0" />,
languages: <path d="M4 6h9M8.5 4v2c0 4-2.2 7.2-5.5 9M6 10c1.5 2.2 3.8 4.1 6.6 5.5M14 18h7M17.5 6l4.5 12M20.3 13h-5.6" />,
moon: <path d="M20 14.5A7.5 7.5 0 1 1 9.5 4 6 6 0 0 0 20 14.5Z" />,
sun: <path d="M12 3v2.2M12 18.8V21M4.9 4.9l1.6 1.6M17.5 17.5l1.6 1.6M3 12h2.2M18.8 12H21M4.9 19.1l1.6-1.6M17.5 6.5l1.6-1.6M12 7.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6Z" />,
'arrow-left': <path d="M19 12H5M12 5l-7 7 7 7" />,
'chevron-left': <path d="m15 18-6-6 6-6" />,
'chevron-right': <path d="m9 18 6-6-6-6" />,
'chevrons-left': <path d="m13.5 17-5-5 5-5M19 17l-5-5 5-5" />,
'chevrons-right': <path d="m10.5 17 5-5-5-5M5 17l5-5-5-5" />,
};
return (

View File

@ -50,6 +50,19 @@ button {
grid-template-columns: 76px minmax(0, 1fr);
}
.app-shell.theme-dark {
--text: #e8edf8;
--muted: #97a3b8;
--paper: rgba(21, 29, 43, 0.88);
--line: rgba(255, 255, 255, 0.08);
--line-strong: rgba(255, 255, 255, 0.14);
--shadow: 0 18px 42px rgba(5, 10, 18, 0.32);
}
.app-shell.theme-dark body {
background: #0e1420;
}
.sidebar {
position: sticky;
top: 0;
@ -92,7 +105,8 @@ button {
.sidebar-collapsed .brand strong,
.sidebar-collapsed .side-nav span,
.sidebar-collapsed .collapse-button span {
.sidebar-collapsed .sidebar-tool-button span,
.sidebar-collapsed .sidebar-tool-button strong {
display: none;
}
@ -144,24 +158,71 @@ button {
}
.collapse-button {
margin-top: auto;
height: 44px;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0 18px;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.92);
width: 40px;
height: 40px;
display: grid;
place-items: center;
margin-top: 10px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: #7f8aa0;
cursor: pointer;
}
.sidebar-tools {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: auto;
margin-bottom: 12px;
}
.sidebar-tool-button {
min-height: 64px;
display: grid;
justify-items: start;
align-content: center;
align-items: center;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.sidebar-collapsed .collapse-button {
justify-content: center;
.sidebar-tool-button .icon {
width: 22px;
height: 22px;
margin-bottom: 2px;
}
.sidebar-tool-button span {
color: #a9b3c4;
font-size: 12px;
font-weight: 700;
}
.sidebar-tool-button strong {
font-size: 13px;
font-weight: 800;
}
.sidebar-collapsed .sidebar-tool-button {
min-height: 40px;
display: grid;
place-items: center;
padding: 0;
}
.sidebar-collapsed .collapse-button {
margin-top: 8px;
}
.workspace {
min-width: 0;
display: flex;
@ -182,6 +243,13 @@ button {
backdrop-filter: blur(22px);
}
.topbar-left {
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
}
.menu-button,
.round-button {
width: 28px;
@ -203,6 +271,32 @@ button {
font-size: 14px;
}
.breadcrumb {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
}
.breadcrumb span {
overflow: hidden;
text-overflow: ellipsis;
}
.breadcrumb-separator {
flex: 0 0 auto;
color: rgba(99, 112, 138, 0.75);
}
.breadcrumb-current {
color: var(--text);
font-weight: 700;
}
.status-item {
display: inline-flex;
align-items: center;
@ -319,6 +413,56 @@ button {
box-shadow: var(--shadow);
}
.app-shell.theme-dark .topbar {
background: rgba(12, 18, 29, 0.84);
}
.app-shell.theme-dark .menu-button,
.app-shell.theme-dark .round-button,
.app-shell.theme-dark .carousel-actions button,
.app-shell.theme-dark .terminal-actions a,
.app-shell.theme-dark .terminal-actions button {
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
.app-shell.theme-dark .sidebar-tool-button,
.app-shell.theme-dark .collapse-button {
background: rgba(255, 255, 255, 0.03);
color: #c7d0de;
}
.app-shell.theme-dark .tab {
color: #a9b5ca;
background: rgba(255, 255, 255, 0.03);
}
.app-shell.theme-dark .tab.active,
.app-shell.theme-dark .activity-card,
.app-shell.theme-dark .service-card,
.app-shell.theme-dark .service-summary,
.app-shell.theme-dark .embed-panel,
.app-shell.theme-dark .terminal-drawer {
background: rgba(21, 29, 43, 0.88);
}
.app-shell.theme-dark .range-tabs .active {
background: rgba(132, 201, 244, 0.22);
color: var(--text);
}
.app-shell.theme-dark .service-card.ghost-card {
background: rgba(29, 40, 58, 0.78);
}
.app-shell.theme-dark .service-card span,
.app-shell.theme-dark .board-heading p,
.app-shell.theme-dark .breadcrumb,
.app-shell.theme-dark .status-strip,
.app-shell.theme-dark .service-row span:last-child {
color: var(--muted);
}
.activity-card {
min-height: 280px;
padding: 24px;
@ -400,7 +544,8 @@ button {
.service-cards {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-flow: column;
grid-auto-columns: minmax(250px, 280px);
gap: 18px;
}
@ -409,6 +554,57 @@ button {
text-decoration: none;
}
.service-carousel {
display: grid;
gap: 14px;
}
.carousel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.carousel-head h2 {
margin: 0;
font-size: 18px;
letter-spacing: 0;
}
.carousel-actions {
display: flex;
gap: 8px;
}
.carousel-actions button {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.82);
cursor: pointer;
}
.service-cards-scroll {
overflow-x: auto;
overflow-y: hidden;
padding: 4px 2px 14px;
scrollbar-width: thin;
scrollbar-color: rgba(76, 105, 167, 0.45) transparent;
}
.service-cards-scroll::-webkit-scrollbar {
height: 8px;
}
.service-cards-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(76, 105, 167, 0.45);
}
.service-card {
min-height: 144px;
display: grid;
@ -427,6 +623,11 @@ button {
transform: rotate(3deg);
}
.service-card.ghost-card {
border-style: dashed;
background: rgba(236, 243, 255, 0.74);
}
.service-card .icon {
color: var(--blue);
}
@ -816,7 +1017,7 @@ button {
@media (max-width: 1280px) {
.service-cards {
grid-template-columns: repeat(2, minmax(160px, 1fr));
grid-auto-columns: minmax(240px, 260px);
}
.core-grid,
@ -853,6 +1054,14 @@ button {
gap: 10px;
}
.topbar-left {
min-width: 100%;
}
.breadcrumb {
max-width: calc(100vw - 96px);
}
.workspace-tabs {
overflow-x: auto;
}
@ -862,11 +1071,15 @@ button {
}
.service-cards {
grid-template-columns: 1fr;
grid-auto-columns: minmax(220px, 86vw);
}
.board-heading {
align-items: flex-start;
flex-direction: column;
}
.sidebar-tools {
grid-template-columns: 1fr;
}
}