feat: 创建 Shell 布局组件(侧边栏 + 底部导航)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
84
src/components/Shell.tsx
Normal file
84
src/components/Shell.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useEffect, type ComponentType } from 'react';
|
||||||
|
|
||||||
|
export interface ModuleConfig {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: ComponentType<{ size?: number; className?: string }>;
|
||||||
|
component: ComponentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHashModule(modules: ModuleConfig[]): string {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
return modules.some((m) => m.id === hash) ? hash : modules[0]?.id ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shell({ modules }: { modules: ModuleConfig[] }) {
|
||||||
|
const [activeModule, setActiveModule] = useState(() => getHashModule(modules));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHashChange = () => setActiveModule(getHashModule(modules));
|
||||||
|
window.addEventListener('hashchange', onHashChange);
|
||||||
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
|
}, [modules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!window.location.hash) {
|
||||||
|
window.location.hash = modules[0]?.id ?? '';
|
||||||
|
}
|
||||||
|
}, [modules]);
|
||||||
|
|
||||||
|
const switchModule = (id: string) => {
|
||||||
|
window.location.hash = id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActiveComponent = modules.find((m) => m.id === activeModule)?.component ?? modules[0]?.component;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Web 侧边栏 (md 及以上) */}
|
||||||
|
<nav className="hidden md:flex flex-col items-center w-16 bg-white border-r border-gray-100 fixed top-0 left-0 h-full z-50 py-6 gap-2">
|
||||||
|
{modules.map((m) => {
|
||||||
|
const Icon = m.icon;
|
||||||
|
const isActive = m.id === activeModule;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => switchModule(m.id)}
|
||||||
|
className={`flex flex-col items-center justify-center w-14 h-14 rounded-xl transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-600'
|
||||||
|
: 'text-gray-400 hover:text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={22} />
|
||||||
|
<span className="text-[10px] mt-1 leading-tight">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* 内容区 */}
|
||||||
|
<main className="flex-1 md:ml-16 pb-16 md:pb-0">
|
||||||
|
{ActiveComponent && <ActiveComponent />}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* 移动端底部导航 (md 以下) */}
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-100 p-2 flex justify-around items-center md:hidden z-50">
|
||||||
|
{modules.map((m) => {
|
||||||
|
const Icon = m.icon;
|
||||||
|
const isActive = m.id === activeModule;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => switchModule(m.id)}
|
||||||
|
className={`flex flex-col items-center ${isActive ? 'text-blue-600' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="text-[10px] mt-1">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user