feat(portal): implement two-tier AppShell header (fix top-left wrapping) and interactive Storybook workbench

Browse Source
This commit is contained in:
Yin Kou committed 2026-11-07 00:17:06 +08:00
1 parent 4ca86da55f
commit 6b589fa62a
11 files changed
+935 -178

No files matched your search

+13 -3
View File
@@ -5,12 +5,16 @@ import { ServiceCard } from './components/ServiceCard';
import { SshShelf } from './components/SshShelf'; import { SshShelf } from './components/SshShelf';
import { CommandPalette } from './components/CommandPalette'; import { CommandPalette } from './components/CommandPalette';
import { AgyTerminalModal } from './components/AgyTerminalModal'; import { AgyTerminalModal } from './components/AgyTerminalModal';
import { StorybookWorkbench } from './components/StorybookWorkbench';
import { LAYOUT_PRESETS } from './design-system/tokens';
import type { ServiceGroup } from './types/telemetry'; import type { ServiceGroup } from './types/telemetry';
export const App: React.FC = () => { export const App: React.FC = () => {
const { const {
telemetry, telemetry,
toast, toast,
viewMode,
layoutPreset,
fetchTelemetry, fetchTelemetry,
setShowCmdPalette, setShowCmdPalette,
getEffectiveMode, getEffectiveMode,
@@ -41,6 +45,8 @@ export const App: React.FC = () => {
return '443 免端口 HTTPS 直达 (*.961504.xyz)'; return '443 免端口 HTTPS 直达 (*.961504.xyz)';
}, [getEffectiveMode, routeMode]); }, [getEffectiveMode, routeMode]);
const gridClass = LAYOUT_PRESETS[layoutPreset]?.gridClass || LAYOUT_PRESETS['homepage-3col'].gridClass;
const serviceGroups = useMemo<ServiceGroup[]>(() => { const serviceGroups = useMemo<ServiceGroup[]>(() => {
const t = telemetry; const t = telemetry;
return [ return [
@@ -176,10 +182,13 @@ export const App: React.FC = () => {
</div> </div>
)} )}
{/* 1. Top Utility & System Resources Ribbon */} {/* 1. Standard Two-Tier AppShell Header & Telemetry Subheader */}
<TopRibbon /> <TopRibbon />
{/* 2. Main Categorized 3-Column Rows */} {/* 2. View Switch: Dashboard vs Storybook Workbench */}
{viewMode === 'storybook' ? (
<StorybookWorkbench />
) : (
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-7"> <main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-7">
{serviceGroups.map((group) => ( {serviceGroups.map((group) => (
<section key={group.title} className="space-y-3"> <section key={group.title} className="space-y-3">
@@ -208,7 +217,7 @@ export const App: React.FC = () => {
)} )}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className={`grid ${gridClass}`}>
{group.items.map((card) => ( {group.items.map((card) => (
<ServiceCard key={card.id} card={card} /> <ServiceCard key={card.id} card={card} />
))} ))}
@@ -219,6 +228,7 @@ export const App: React.FC = () => {
{/* 3. Bottom SSH Quick Copy & Maintenance Shelf */} {/* 3. Bottom SSH Quick Copy & Maintenance Shelf */}
<SshShelf /> <SshShelf />
</main> </main>
)}
{/* 4. Global Cmd+K Command Palette */} {/* 4. Global Cmd+K Command Palette */}
<CommandPalette /> <CommandPalette />
@@ -0,0 +1,388 @@
import React, { useState } from 'react';
import { BookOpen, Check, Layers, Layout, Palette, Sparkles, Terminal } from 'lucide-react';
import { LAYOUT_PRESETS, THEME_PALETTES, type LayoutPresetId } from '../design-system/tokens';
import { usePortalStore } from '../store/usePortalStore';
import { ServiceCard } from './ServiceCard';
import { SshShelf } from './SshShelf';
type StoryId = 'layout-architecture' | 'theme-tokens' | 'service-card' | 'ssh-shelf';
export const StorybookWorkbench: React.FC = () => {
const [activeStory, setActiveStory] = useState<StoryId>('layout-architecture');
const {
theme,
resolvedTheme,
layoutPreset,
setTheme,
setLayoutPreset,
setViewMode,
telemetry,
} = usePortalStore();
// Interactive Playground Props for ServiceCard story
const [demoOnline, setDemoOnline] = useState(true);
const [demoAction, setDemoAction] = useState<'drawer' | 'backup' | undefined>('drawer');
const stories: Array<{ id: StoryId; group: string; title: string; icon: React.ReactNode }> = [
{
id: 'layout-architecture',
group: 'FOUNDATIONS / 架构与布局',
title: '01. AppShell 双层顶栏与网格布局',
icon: <Layout className="w-3.5 h-3.5" />,
},
{
id: 'theme-tokens',
group: 'FOUNDATIONS / 架构与布局',
title: '02. 双主题色板与自动切换规范',
icon: <Palette className="w-3.5 h-3.5" />,
},
{
id: 'service-card',
group: 'COMPONENTS / 核心组件',
title: '03. ServiceCard 内联遥测卡片',
icon: <Layers className="w-3.5 h-3.5" />,
},
{
id: 'ssh-shelf',
group: 'COMPONENTS / 核心组件',
title: '04. SshShelf 快捷指令栏',
icon: <Terminal className="w-3.5 h-3.5" />,
},
];
return (
<div className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
{/* Left Sidebar: Storybook Navigation Tree */}
<aside className="hl-card p-4 space-y-4 lg:sticky lg:top-20">
<div className="flex items-center justify-between border-b hl-border pb-3">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4" style={{ color: 'var(--accent)' }} />
<span className="text-xs font-bold uppercase tracking-wider">Storybook 组件库</span>
</div>
<span className="text-[10px] font-mono-num px-1.5 py-0.5 rounded hl-muted">v3.1</span>
</div>
<div className="space-y-1">
{stories.map((s) => (
<button
key={s.id}
onClick={() => setActiveStory(s.id)}
className="w-full px-3 py-2 rounded-lg text-left text-xs font-medium flex items-center gap-2.5 transition-all cursor-pointer"
style={
activeStory === s.id
? {
backgroundColor: 'var(--accent-soft)',
color: 'var(--accent)',
}
: { color: 'var(--text-muted)' }
}
>
{s.icon}
<span className="truncate">{s.title}</span>
</button>
))}
</div>
<div className="pt-3 border-t hl-border space-y-2 text-xs">
<div className="text-[11px] font-semibold" style={{ color: 'var(--text-muted)' }}>
全局外观状态预览
</div>
<div className="grid grid-cols-3 gap-1 p-1 rounded-lg hl-muted border hl-border">
{(['system', 'light', 'dark'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className="py-1 rounded text-[11px] font-medium cursor-pointer"
style={
theme === t
? { backgroundColor: 'var(--bg-card)', color: 'var(--text-main)' }
: { color: 'var(--text-muted)' }
}
>
{t === 'system' ? '自动' : t === 'light' ? '浅色' : '深色'}
</button>
))}
</div>
<button
onClick={() => setViewMode('dashboard')}
className="w-full hl-btn-primary py-2 text-xs text-center mt-2 cursor-pointer"
>
返回中控台主页
</button>
</div>
</aside>
{/* Right Canvas: Interactive Story Preview & Controls */}
<div className="lg:col-span-3 space-y-6">
{/* STORY 1: Layout Architecture */}
{activeStory === 'layout-architecture' && (
<div className="space-y-6">
<div className="hl-card p-6 space-y-4">
<div className="flex items-center justify-between border-b hl-border pb-4">
<div>
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
Foundations / AppShell Layout
</span>
<h2 className="text-lg font-bold mt-0.5">
标准双层顶栏架构 (Two-Tier AppShell) & 响应式网格切换
</h2>
</div>
</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
参考 <strong>Vercel Geist / shadcn/ui Dashboard-01 / GitHub AppShell</strong>{' '}
标准规范,将原本容易挤压折行的单层顶栏拆分为职责清晰的双层结构:
</p>
{/* Visual Blueprint Diagram of Two-Tier Header */}
<div className="p-4 rounded-xl hl-muted border hl-border space-y-2.5 font-mono-num text-xs">
<div className="p-3 rounded-lg hl-card flex items-center justify-between">
<span>[左] 🍎 HomeLab · 导航切换 (中控台 / Storybook)</span>
<span style={{ color: 'var(--accent)' }}>
[最右固定] 🔍 搜索 ⌘K · &gt;_ AGY 终端 · ☀️/🌙 主题切换
</span>
</div>
<div className="p-3 rounded-lg hl-card flex items-center justify-between">
<span style={{ color: 'var(--success)' }}>
[左] 实时硬件遥测条 (M6 CPU · RAM · Z4 20TB · SG 专线)
</span>
<span>[右对齐] 路由模式切换器 (智能路由 / 443 / 10443 / LAN)</span>
</div>
</div>
{/* Interactive Grid Preset Selector */}
<div className="pt-2 space-y-3">
<div className="text-xs font-semibold">
选择主页卡片网格布局预设(点击立即应用到主页):
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3.5">
{(Object.keys(LAYOUT_PRESETS) as LayoutPresetId[]).map((key) => {
const preset = LAYOUT_PRESETS[key];
const active = layoutPreset === key;
return (
<div
key={key}
onClick={() => setLayoutPreset(key)}
className="hl-card hl-card-interactive p-4 cursor-pointer space-y-2"
style={active ? { borderColor: 'var(--accent)' } : {}}
>
<div className="flex items-center justify-between">
<span className="text-xs font-bold">{preset.name}</span>
{active && (
<span
className="w-4 h-4 rounded-full flex items-center justify-center"
style={{
backgroundColor: 'var(--accent)',
color: '#fff',
}}
>
<Check className="w-2.5 h-2.5" />
</span>
)}
</div>
<p className="text-[11px]" style={{ color: 'var(--text-muted)' }}>
{preset.description}
</p>
<code
className="text-[10px] font-mono-num block pt-1"
style={{ color: 'var(--accent)' }}
>
{preset.gridClass}
</code>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
{/* STORY 2: Theme Tokens */}
{activeStory === 'theme-tokens' && (
<div className="hl-card p-6 space-y-6">
<div className="border-b hl-border pb-4 flex items-center justify-between">
<div>
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
Foundations / Color System
</span>
<h2 className="text-lg font-bold mt-0.5">
主流双主题色板规范(当前生效:{resolvedTheme === 'dark' ? '深色极客' : '浅色纯净'})
</h2>
</div>
<span className="text-xs font-mono-num px-2.5 py-1 rounded hl-muted">
模式: {theme === 'system' ? '跟随设备自动切换' : '手动锁定'}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{(['light', 'dark'] as const).map((modeKey) => {
const p = THEME_PALETTES[modeKey];
return (
<div
key={p.id}
className="rounded-xl p-5 space-y-4 border"
style={{
backgroundColor: p.tokens['--bg-page'],
color: p.tokens['--text-main'],
borderColor: p.tokens['--border-color'],
}}
>
<div
className="flex items-center justify-between border-b pb-3"
style={{ borderColor: p.tokens['--border-color'] }}
>
<div>
<div className="text-sm font-bold">{p.name}</div>
<div className="text-xs mt-0.5" style={{ color: p.tokens['--text-muted'] }}>
{p.description}
</div>
</div>
<button
onClick={() => setTheme(modeKey)}
className="px-2.5 py-1 rounded-md text-xs font-mono-num font-semibold cursor-pointer"
style={{
backgroundColor: p.tokens['--primary'],
color: p.tokens['--primary-fg'],
}}
>
预览此主题
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(p.tokens).map(([tokenName, hex]) => (
<div
key={tokenName}
className="p-2 rounded-lg border flex items-center gap-2"
style={{
backgroundColor: p.tokens['--bg-card'],
borderColor: p.tokens['--border-color'],
}}
>
<span
className="w-5 h-5 rounded border shrink-0"
style={{
backgroundColor: hex,
borderColor: p.tokens['--border-hover'],
}}
/>
<div className="min-w-0">
<div className="text-[10px] font-mono-num font-semibold truncate">
{tokenName}
</div>
<div
className="text-[10px] font-mono-num truncate"
style={{ color: p.tokens['--text-muted'] }}
>
{hex}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{/* STORY 3: ServiceCard Interactive Playground */}
{activeStory === 'service-card' && (
<div className="hl-card p-6 space-y-6">
<div className="border-b hl-border pb-4 flex items-center justify-between">
<div>
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
Components / ServiceCard
</span>
<h2 className="text-lg font-bold mt-0.5">
三段式内联遥测卡片 (ServiceCard Interactive Story)
</h2>
</div>
<Sparkles className="w-4 h-4" style={{ color: 'var(--accent)' }} />
</div>
{/* Interactive Controls */}
<div className="p-4 rounded-xl hl-muted border hl-border flex flex-wrap items-center gap-6 text-xs">
<div className="flex items-center gap-2">
<span className="font-semibold">在线状态 (online):</span>
<button
onClick={() => setDemoOnline(!demoOnline)}
className="px-2.5 py-1 rounded hl-card font-mono-num cursor-pointer"
>
{demoOnline ? 'true (绿灯)' : 'false (离线红灯)'}
</button>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold">右上角动作按钮 (actionType):</span>
{(['drawer', 'backup', undefined] as const).map((act, i) => (
<button
key={i}
onClick={() => setDemoAction(act)}
className="px-2.5 py-1 rounded hl-card font-mono-num cursor-pointer"
style={demoAction === act ? { borderColor: 'var(--accent)' } : {}}
>
{act || 'route-badge'}
</button>
))}
</div>
</div>
{/* Live Component Canvas */}
<div className="max-w-md mx-auto py-4">
<ServiceCard
card={{
id: 'agy',
title: 'AGY 云端工作台',
subtitle: 'agy.961504.xyz · ttyd + tmux',
iconUrl:
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png',
online: demoOnline,
actionType: demoAction,
desc: 'M6 Mac mini 上的 24 小时常驻 AI 编程终端,已同步 Google 登录态并锁定新加坡出口,关掉网页后台继续运行。',
edgeUrl: 'https://agy.961504.xyz',
homeUrl: 'https://agy.home.961504.xyz:10443',
lanUrl: 'http://192.168.31.164:7681',
stats: [
{ label: 'M6 CPU 负载', value: `${telemetry.mini.cpu_pct}%` },
{ label: '已用内存', value: `${telemetry.mini.mem_used_gb} / 24 GB` },
{ label: 'tmux 会话', value: '常驻运行中', highlight: true },
],
}}
/>
</div>
</div>
)}
{/* STORY 4: SshShelf */}
{activeStory === 'ssh-shelf' && (
<div className="hl-card p-6 space-y-6">
<div className="border-b hl-border pb-4">
<span
className="text-[11px] font-mono-num uppercase tracking-wider"
style={{ color: 'var(--accent)' }}
>
Components / SshShelf
</span>
<h2 className="text-lg font-bold mt-0.5">底部 SSH 快捷复制与运维操作栏</h2>
</div>
<SshShelf />
</div>
)}
</div>
</div>
</div>
);
};
+144 -83
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Moon, Search, Sun, Terminal } from 'lucide-react'; import { BookOpen, LayoutGrid, Moon, Search, Sun, Terminal } from 'lucide-react';
import { usePortalStore } from '../store/usePortalStore'; import { usePortalStore } from '../store/usePortalStore';
import type { RouteModeId } from '../types/telemetry'; import type { RouteModeId } from '../types/telemetry';
@@ -15,23 +15,34 @@ export const TopRibbon: React.FC = () => {
theme, theme,
resolvedTheme, resolvedTheme,
routeMode, routeMode,
viewMode,
telemetry, telemetry,
setTheme, setTheme,
toggleTheme, toggleTheme,
setRouteMode, setRouteMode,
setViewMode,
setShowAgyModal, setShowAgyModal,
setShowCmdPalette, setShowCmdPalette,
} = usePortalStore(); } = usePortalStore();
return ( return (
<>
{/* =====================================================================
Tier 1: Standard AppShell Global Top Navbar (h-14, NEVER wraps!)
Left: Brand + View Tabs (中控台 / Storybook)
Far Right (Red Box): Search (Cmd+K) + AGY Terminal + Theme Switcher
===================================================================== */}
<header <header
className="border-b hl-border sticky top-0 z-30 backdrop-blur-md" className="border-b hl-border sticky top-0 z-30 backdrop-blur-md"
style={{ backgroundColor: 'color-mix(in srgb, var(--bg-page) 86%, transparent)' }} style={{ backgroundColor: 'color-mix(in srgb, var(--bg-card) 88%, transparent)' }}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between gap-4">
{/* Left Group: Brand Logo + Navigation Tabs */}
<div className="flex items-center gap-4 shrink-0">
<div
onClick={() => setViewMode('dashboard')}
className="flex items-center gap-2.5 cursor-pointer"
> >
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 flex flex-wrap items-center justify-between gap-3">
{/* Left: Brand + Live System Micro-Gauges Ribbon */}
<div className="flex flex-wrap items-center gap-2.5 sm:gap-3">
<div className="flex items-center gap-2.5 pr-1">
<img <img
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png" src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png"
alt="Mac mini" alt="Mac mini"
@@ -45,66 +56,54 @@ export const TopRibbon: React.FC = () => {
/> />
</div> </div>
<div className="hidden xl:flex items-center gap-2 text-xs font-mono-num"> <div className="h-4 w-[1px] hl-muted" />
{/* CPU Gauge */}
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="Apple M6 12核处理器负载"> {/* View Switcher Tabs: Dashboard vs Storybook */}
<span style={{ color: 'var(--text-muted)' }}>M6 CPU</span> <nav className="flex items-center gap-1 text-xs">
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden"> <button
<div onClick={() => setViewMode('dashboard')}
className="h-full hl-bar-fill" className="px-2.5 py-1.5 rounded-md font-medium flex items-center gap-1.5 transition-all cursor-pointer"
style={{ width: `${Math.max(5, telemetry.mini.cpu_pct)}%` }} style={
/> viewMode === 'dashboard'
</div> ? {
<span className="font-medium">{telemetry.mini.cpu_pct}%</span> backgroundColor: 'var(--bg-muted)',
color: 'var(--text-main)',
}
: { color: 'var(--text-muted)' }
}
>
<LayoutGrid className="w-3.5 h-3.5" />
<span>中控台</span>
</button>
<button
onClick={() => setViewMode('storybook')}
className="px-2.5 py-1.5 rounded-md font-medium flex items-center gap-1.5 transition-all cursor-pointer"
style={
viewMode === 'storybook'
? {
backgroundColor: 'var(--bg-muted)',
color: 'var(--text-main)',
}
: { color: 'var(--text-muted)' }
}
>
<BookOpen className="w-3.5 h-3.5" />
<span>Storybook 设计规范</span>
</button>
</nav>
</div> </div>
{/* RAM Gauge */} {/* Far Right Group: Pinned to Right Edge (Search + AGY + Theme Switcher) */}
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="Mac mini 24GB 统一内存"> <div className="flex items-center gap-2.5 shrink-0 ml-auto">
<span style={{ color: 'var(--text-muted)' }}>RAM</span>
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
<div
className="h-full hl-bar-fill"
style={{ width: `${Math.max(5, telemetry.mini.mem_pct)}%` }}
/>
</div>
<span className="font-medium">{telemetry.mini.mem_used_gb}G</span>
</div>
{/* ZSpace 20TB Gauge */}
<div className="hl-pill px-2.5 py-1 flex items-center gap-2" title="极空间 Z4 20TB 存储阵列">
<span style={{ color: 'var(--text-muted)' }}>NAS 余量</span>
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
<div
className="h-full hl-bar-fill-green"
style={{ width: `${Math.max(5, telemetry.nas.pct)}%` }}
/>
</div>
<span className="font-medium" style={{ color: 'var(--success)' }}>
{telemetry.nas.free_tb} TB
</span>
</div>
{/* SG AI Line Gauge */}
<div className="hl-pill px-2.5 py-1 flex items-center gap-1.5" title="AGY 锁定新加坡 Azure 专线出口">
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
<span style={{ color: 'var(--text-muted)' }}>SG 专线</span>
<span className="font-medium">
{telemetry.network.sg_delay_ms > 0 ? `${telemetry.network.sg_delay_ms}ms` : '直连'}
</span>
</div>
</div>
</div>
{/* Right: Search (Cmd+K), Route Switcher, AGY Terminal, Top-Right Theme Switcher */}
<div className="flex items-center gap-2">
<button <button
onClick={() => setShowCmdPalette(true)} onClick={() => setShowCmdPalette(true)}
className="hl-btn-outline px-2.5 py-1 text-xs flex items-center gap-3 cursor-pointer" className="hl-btn-outline px-3 py-1.5 text-xs flex items-center gap-3 cursor-pointer"
title="按 Cmd+K 唤起全局命令面板" title="按 Cmd+K 唤起全局命令面板"
> >
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}> <span className="flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
<Search className="w-3.5 h-3.5" /> <Search className="w-3.5 h-3.5" />
<span className="hidden sm:inline">搜索...</span> <span className="hidden sm:inline">搜索服务或指令...</span>
</span> </span>
<kbd <kbd
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted" className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
@@ -114,27 +113,6 @@ export const TopRibbon: React.FC = () => {
</kbd> </kbd>
</button> </button>
<div className="hidden md:flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
{MODES.map((m) => (
<button
key={m.id}
onClick={() => setRouteMode(m.id)}
className="px-2 py-1 rounded-md font-medium transition-all cursor-pointer"
style={
routeMode === m.id
? {
backgroundColor: 'var(--bg-card)',
color: 'var(--text-main)',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
}
: { color: 'var(--text-muted)' }
}
>
{m.label}
</button>
))}
</div>
<button <button
onClick={() => setShowAgyModal(true)} onClick={() => setShowAgyModal(true)}
className="hl-btn-primary px-3 py-1.5 text-xs flex items-center gap-1.5 cursor-pointer" className="hl-btn-primary px-3 py-1.5 text-xs flex items-center gap-1.5 cursor-pointer"
@@ -143,7 +121,7 @@ export const TopRibbon: React.FC = () => {
<span>AGY 终端</span> <span>AGY 终端</span>
</button> </button>
{/* Top-Right Mainstream Theme Switcher (Auto System + Light/Dark 2 Mainstream Themes) */} {/* Top-Right Theme Toggle (Pinned at Far Right Corner!) */}
<div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs"> <div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
<button <button
onClick={toggleTheme} onClick={toggleTheme}
@@ -153,17 +131,17 @@ export const TopRibbon: React.FC = () => {
color: 'var(--text-main)', color: 'var(--text-main)',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)', boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
}} }}
title="点击切换深色 / 浅色主题" title="点击切换深色 / 浅色主题(默认跟随系统)"
> >
{resolvedTheme === 'dark' ? ( {resolvedTheme === 'dark' ? (
<> <>
<Moon className="w-3.5 h-3.5" /> <Moon className="w-3.5 h-3.5" />
<span className="hidden sm:inline">深色</span> <span>深色</span>
</> </>
) : ( ) : (
<> <>
<Sun className="w-3.5 h-3.5" /> <Sun className="w-3.5 h-3.5" />
<span className="hidden sm:inline">浅色</span> <span>浅色</span>
</> </>
)} )}
</button> </button>
@@ -181,5 +159,88 @@ export const TopRibbon: React.FC = () => {
</div> </div>
</div> </div>
</header> </header>
{/* =====================================================================
Tier 2: Subheader Telemetry & Route Mode Bar (Aligned with Grid)
Left: 4 Hardware & Network Gauges
Right: Route Mode Segmented Switcher (智能路由 / 443 / 10443 / LAN)
===================================================================== */}
{viewMode === 'dashboard' && (
<div className="border-b hl-border" style={{ backgroundColor: 'var(--bg-card)' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5 flex flex-wrap items-center justify-between gap-3">
{/* Left: System Telemetry Strip */}
<div className="flex flex-wrap items-center gap-2 text-xs font-mono-num">
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style={{ color: 'var(--text-muted)' }}>M6 CPU</span>
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
<div
className="h-full hl-bar-fill"
style={{ width: `${Math.max(5, telemetry.mini.cpu_pct)}%` }}
/>
</div>
<span className="font-medium">{telemetry.mini.cpu_pct}%</span>
</div>
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style={{ color: 'var(--text-muted)' }}>RAM</span>
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
<div
className="h-full hl-bar-fill"
style={{ width: `${Math.max(5, telemetry.mini.mem_pct)}%` }}
/>
</div>
<span className="font-medium">{telemetry.mini.mem_used_gb}G</span>
</div>
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
<span style={{ color: 'var(--text-muted)' }}>NAS 余量</span>
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
<div
className="h-full hl-bar-fill-green"
style={{ width: `${Math.max(5, telemetry.nas.pct)}%` }}
/>
</div>
<span className="font-medium" style={{ color: 'var(--success)' }}>
{telemetry.nas.free_tb} TB
</span>
</div>
<div className="hl-pill px-2.5 py-1 flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: 'var(--success)' }}
/>
<span style={{ color: 'var(--text-muted)' }}>SG 专线</span>
<span className="font-medium">
{telemetry.network.sg_delay_ms > 0 ? `${telemetry.network.sg_delay_ms}ms` : '直连'}
</span>
</div>
</div>
{/* Right: Route Mode Switcher (Pinned to Right Edge of Subheader) */}
<div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs ml-auto">
{MODES.map((m) => (
<button
key={m.id}
onClick={() => setRouteMode(m.id)}
className="px-2.5 py-1 rounded-md font-medium transition-all cursor-pointer"
style={
routeMode === m.id
? {
backgroundColor: 'var(--bg-card)',
color: 'var(--text-main)',
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
}
: { color: 'var(--text-muted)' }
}
>
{m.label}
</button>
))}
</div>
</div>
</div>
)}
</>
); );
}; };
@@ -0,0 +1,135 @@
import React from 'react';
import { THEME_PALETTES, LAYOUT_PRESETS } from './tokens';
export const DesignTokensShowcase: React.FC = () => {
return (
<div className="p-6 sm:p-8 space-y-10 max-w-6xl mx-auto">
{/* Header */}
<div className="space-y-2 border-b hl-border pb-5">
<div className="text-xs font-mono-num uppercase tracking-widest" style={{ color: 'var(--accent)' }}>
HomeLab Design System · Storybook v3.0
</div>
<h1 className="text-2xl font-bold tracking-tight">主题色彩、排版与布局规范 (Design Tokens)</h1>
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
全系统仅保留两套高标准主流主题(浅色纯净 Light & 深色极客 Dark),默认通过{' '}
<code className="font-mono-num px-1.5 py-0.5 rounded hl-muted">prefers-color-scheme</code>{' '}
自动跟随设备切换,并支持右上角一键手动覆盖。
</p>
</div>
{/* Side-by-Side Theme Palettes */}
<section className="space-y-4">
<h2 className="text-base font-semibold">1. 双主题色彩变量对照表 (Dual Mainstream Palettes)</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{(['light', 'dark'] as const).map((key) => {
const p = THEME_PALETTES[key];
return (
<div
key={p.id}
className="rounded-xl p-5 space-y-4 border"
style={{
backgroundColor: p.tokens['--bg-page'],
color: p.tokens['--text-main'],
borderColor: p.tokens['--border-color'],
}}
>
<div className="flex items-center justify-between border-b pb-3" style={{ borderColor: p.tokens['--border-color'] }}>
<div>
<div className="text-sm font-bold">{p.name}</div>
<div className="text-xs mt-0.5" style={{ color: p.tokens['--text-muted'] }}>
{p.description}
</div>
</div>
<span
className="px-2.5 py-1 rounded-md text-xs font-mono-num font-semibold"
style={{
backgroundColor: p.tokens['--primary'],
color: p.tokens['--primary-fg'],
}}
>
{p.id.toUpperCase()}
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5">
{Object.entries(p.tokens).map(([tokenName, hex]) => (
<div
key={tokenName}
className="p-2.5 rounded-lg border flex items-center gap-2.5"
style={{
backgroundColor: p.tokens['--bg-card'],
borderColor: p.tokens['--border-color'],
}}
>
<span
className="w-6 h-6 rounded-md border shrink-0"
style={{
backgroundColor: hex,
borderColor: p.tokens['--border-hover'],
}}
/>
<div className="min-w-0">
<div className="text-[11px] font-mono-num font-semibold truncate">{tokenName}</div>
<div className="text-[10px] font-mono-num truncate" style={{ color: p.tokens['--text-muted'] }}>
{hex}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</section>
{/* Typography Scale */}
<section className="space-y-4">
<h2 className="text-base font-semibold">2. 字体与等宽数字规范 (Typography & Tabular Numbers)</h2>
<div className="hl-card p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<div className="text-xs font-mono-num uppercase" style={{ color: 'var(--text-muted)' }}>
UI Sans Font · Inter / SF Pro Display
</div>
<div className="text-xl font-bold">AGY 云端工作台 · 极空间 Z4 私有云</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
正文描述统一采用 12px (0.75rem) 搭配 1.625 行高,限制最最多 2 行截断 (line-clamp-2),确保所有卡片高度严格对齐。
</p>
</div>
<div className="space-y-2">
<div className="text-xs font-mono-num uppercase" style={{ color: 'var(--text-muted)' }}>
Telemetry Mono Font · JetBrains Mono (tabular-nums)
</div>
<div className="text-xl font-bold font-mono-num" style={{ color: 'var(--success)' }}>
16.58 TB FREE · 68 ms · 104.43.105.243
</div>
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
所有实时跳动的 CPU 百分比、内存、存储余量、网络延迟强制启用{' '}
<code className="font-mono-num">font-variant-numeric: tabular-nums</code>,杜绝每 3 秒刷新时产生像素抖动。
</p>
</div>
</div>
</div>
</section>
{/* Layout Grid Presets */}
<section className="space-y-4">
<h2 className="text-base font-semibold">3. 响应式布局网格预设 (Layout Grid Presets)</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Object.values(LAYOUT_PRESETS).map((lp) => (
<div key={lp.id} className="hl-card p-4 space-y-2">
<div className="text-sm font-semibold">{lp.name}</div>
<code className="text-[11px] font-mono-num block" style={{ color: 'var(--accent)' }}>
{lp.gridClass}
</code>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
{lp.description}
</p>
</div>
))}
</div>
</section>
</div>
);
};
@@ -0,0 +1,102 @@
export interface ThemePalette {
id: 'light' | 'dark';
name: string;
description: string;
tokens: {
'--bg-page': string;
'--bg-card': string;
'--bg-widget': string;
'--bg-muted': string;
'--bg-hover': string;
'--text-main': string;
'--text-muted': string;
'--text-subtle': string;
'--border-color': string;
'--border-hover': string;
'--primary': string;
'--primary-fg': string;
'--accent': string;
'--accent-soft': string;
'--success': string;
'--success-soft': string;
};
}
export const THEME_PALETTES: Record<'light' | 'dark', ThemePalette> = {
light: {
id: 'light',
name: 'Shadcn Clean Light (浅色纯净)',
description: '对标 Apple / Stripe / Linear 浅色日光模式,高对比深灰文字配纯净白卡片与冷灰微边框。',
tokens: {
'--bg-page': '#f8fafc',
'--bg-card': '#ffffff',
'--bg-widget': '#f8fafc',
'--bg-muted': '#f1f5f9',
'--bg-hover': '#ffffff',
'--text-main': '#0f172a',
'--text-muted': '#64748b',
'--text-subtle': '#94a3b8',
'--border-color': '#e2e8f0',
'--border-hover': '#cbd5e1',
'--primary': '#0f172a',
'--primary-fg': '#ffffff',
'--accent': '#2563eb',
'--accent-soft': 'rgba(37, 99, 235, 0.08)',
'--success': '#10b981',
'--success-soft': 'rgba(16, 185, 129, 0.10)',
},
},
dark: {
id: 'dark',
name: 'Catppuccin / Linear Dark (深色极客)',
description: '融合 Catppuccin Mocha 与 Linear 暗黑规范,柔和护眼的深邃蓝黑基底配高辨识度状态色。',
tokens: {
'--bg-page': '#11111b',
'--bg-card': '#1e1e2e',
'--bg-widget': '#181825',
'--bg-muted': '#313244',
'--bg-hover': '#242438',
'--text-main': '#cdd6f4',
'--text-muted': '#a6adc8',
'--text-subtle': '#6c7086',
'--border-color': '#313244',
'--border-hover': '#45475a',
'--primary': '#cba6f7',
'--primary-fg': '#11111b',
'--accent': '#89b4fa',
'--accent-soft': 'rgba(137, 180, 250, 0.15)',
'--success': '#a6e3a1',
'--success-soft': 'rgba(166, 227, 161, 0.15)',
},
},
};
export type LayoutPresetId = 'homepage-3col' | 'bento-split' | 'compact-2col';
export interface LayoutPreset {
id: LayoutPresetId;
name: string;
description: string;
gridClass: string;
}
export const LAYOUT_PRESETS: Record<LayoutPresetId, LayoutPreset> = {
'homepage-3col': {
id: 'homepage-3col',
name: 'Homepage 经典三列矩阵 (3-Column Grid)',
description: 'gethomepage.dev 标志性对称 3 列分组卡片,底部内嵌 3 格实时遥测单元。',
gridClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4',
},
'bento-split': {
id: 'bento-split',
name: 'Shadcn 双列宽屏卡片 (2-Column Wide Bento)',
description: '适合大字号阅读的 2 列宽体卡片布局,留白更充裕。',
gridClass: 'grid-cols-1 lg:grid-cols-2 gap-5',
},
'compact-2col': {
id: 'compact-2col',
name: '高密度四列紧凑矩阵 (4-Column Dense)',
description: '适合超宽显示器的一屏高密度 4 列布局。',
gridClass: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5',
},
};
@@ -1,10 +1,13 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { RouteModeId, TelemetryData, ThemeId } from '../types/telemetry'; import type { RouteModeId, TelemetryData, ThemeId } from '../types/telemetry';
import type { LayoutPresetId } from '../design-system/tokens';
interface PortalState { interface PortalState {
theme: ThemeId; theme: ThemeId;
resolvedTheme: 'light' | 'dark'; resolvedTheme: 'light' | 'dark';
routeMode: RouteModeId; routeMode: RouteModeId;
layoutPreset: LayoutPresetId;
viewMode: 'dashboard' | 'storybook';
showAgyModal: boolean; showAgyModal: boolean;
showCmdPalette: boolean; showCmdPalette: boolean;
toast: string; toast: string;
@@ -13,6 +16,8 @@ interface PortalState {
setTheme: (theme: ThemeId) => void; setTheme: (theme: ThemeId) => void;
toggleTheme: () => void; toggleTheme: () => void;
setRouteMode: (mode: RouteModeId) => void; setRouteMode: (mode: RouteModeId) => void;
setLayoutPreset: (preset: LayoutPresetId) => void;
setViewMode: (view: 'dashboard' | 'storybook') => void;
setShowAgyModal: (open: boolean) => void; setShowAgyModal: (open: boolean) => void;
setShowCmdPalette: (open: boolean) => void; setShowCmdPalette: (open: boolean) => void;
showToast: (msg: string) => void; showToast: (msg: string) => void;
@@ -43,11 +48,19 @@ const initialTheme: ThemeId =
: 'system'; : 'system';
const initialResolved = applyDomTheme(initialTheme); const initialResolved = applyDomTheme(initialTheme);
const initialRouteMode = (localStorage.getItem('hl_route_mode') as RouteModeId) || 'auto'; const initialRouteMode = (localStorage.getItem('hl_route_mode') as RouteModeId) || 'auto';
const initialLayout =
(localStorage.getItem('hl_layout_preset') as LayoutPresetId) || 'homepage-3col';
const initialView =
typeof window !== 'undefined' && window.location.pathname.startsWith('/storybook')
? 'storybook'
: 'dashboard';
export const usePortalStore = create<PortalState>((set, get) => ({ export const usePortalStore = create<PortalState>((set, get) => ({
theme: initialTheme, theme: initialTheme,
resolvedTheme: initialResolved, resolvedTheme: initialResolved,
routeMode: initialRouteMode, routeMode: initialRouteMode,
layoutPreset: initialLayout,
viewMode: initialView,
showAgyModal: false, showAgyModal: false,
showCmdPalette: false, showCmdPalette: false,
toast: '', toast: '',
@@ -111,6 +124,19 @@ export const usePortalStore = create<PortalState>((set, get) => ({
set({ routeMode }); set({ routeMode });
}, },
setLayoutPreset: (layoutPreset) => {
localStorage.setItem('hl_layout_preset', layoutPreset);
set({ layoutPreset });
},
setViewMode: (viewMode) => {
if (typeof window !== 'undefined') {
const nextUrl = viewMode === 'storybook' ? '/storybook' : '/';
window.history.pushState({}, '', nextUrl);
}
set({ viewMode });
},
setShowAgyModal: (showAgyModal) => set({ showAgyModal }), setShowAgyModal: (showAgyModal) => set({ showAgyModal }),
setShowCmdPalette: (showCmdPalette) => set({ showCmdPalette }), setShowCmdPalette: (showCmdPalette) => set({ showCmdPalette }),