chore: add frontend/src/components/layout/Sidebar.tsx

This commit is contained in:
2026-04-29 10:53:28 +08:00
parent 76f078c5b4
commit 99d175c17d
+290
View File
@@ -0,0 +1,290 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useUiStore } from '../../stores';
const Sidebar: React.FC = () => {
const location = useLocation();
const { sidebarCollapsed, toggleSidebar } = useUiStore();
const navItems = [
{ path: '/', label: '首页', icon: HomeIcon },
{ path: '/record', label: '记账', icon: RecordIcon },
{ path: '/budget', label: '预算', icon: BudgetIcon },
{ path: '/statistics', label: '统计', icon: StatisticsIcon },
];
return (
<aside
className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}
role="navigation"
aria-label="侧边导航"
>
<div className="sidebar-logo">
<div className="sidebar-logo-content">
<LogoIcon />
{!sidebarCollapsed && <span></span>}
</div>
<div
className="sidebar-toggle-wrapper"
data-tooltip={sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
<button
className="sidebar-toggle"
type="button"
aria-label="折叠/展开侧边栏"
onClick={toggleSidebar}
>
<ChevronIcon />
</button>
</div>
</div>
<nav className="sidebar-nav">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`sidebar-nav-item ${isActive ? 'active' : ''}`
}
aria-current={location.pathname === item.path ? 'page' : undefined}
>
<item.icon />
{!sidebarCollapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
<style>{`
:root {
--primary: #0052ff;
--primary-hover: #3761ff;
--primary-light: rgba(0, 82, 255, 0.1);
--surface: #ffffff;
--border: #e5e5e5;
--text-primary: #1a1a1a;
--text-secondary: #737373;
--radius-md: 8px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
}
.sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: var(--space-6);
position: fixed;
top: 0;
left: 0;
bottom: 0;
overflow-y: auto;
z-index: 100;
transition: width 0.3s ease, padding 0.3s ease;
}
.sidebar.collapsed {
width: 64px;
padding: var(--space-6) var(--space-2);
}
.sidebar-logo {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.sidebar-logo-content {
display: flex;
align-items: center;
gap: var(--space-2);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.sidebar-nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
color: var(--text-secondary);
text-decoration: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
}
.sidebar-nav-item:hover {
background: var(--primary-light);
color: var(--text-primary);
}
.sidebar-nav-item.active {
background: var(--primary-light);
color: var(--primary);
}
.sidebar.collapsed .sidebar-nav-item {
justify-content: center;
padding: var(--space-3);
}
.sidebar.collapsed .sidebar-nav-item span {
display: none;
}
.sidebar-toggle {
width: 36px;
height: 36px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
color: var(--text-secondary);
}
.sidebar-toggle:hover {
background: var(--primary-light);
color: var(--primary);
border-color: var(--primary);
}
.sidebar.collapsed .sidebar-toggle svg {
transform: rotate(180deg);
}
.sidebar-toggle svg {
transition: transform 0.3s ease;
}
.sidebar-toggle-wrapper {
position: relative;
display: inline-flex;
}
.sidebar-toggle-wrapper::after {
content: attr(data-tooltip);
position: absolute;
left: calc(100% + 12px);
top: 50%;
transform: translateY(-50%);
background: #1a1a1a;
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.15s ease;
z-index: 1000;
pointer-events: none;
}
.sidebar-toggle-wrapper::before {
content: '';
position: absolute;
left: calc(100% + 4px);
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-right-color: #1a1a1a;
opacity: 0;
visibility: hidden;
transition: all 0.15s ease;
z-index: 1000;
pointer-events: none;
}
.sidebar-toggle-wrapper:hover::after,
.sidebar-toggle-wrapper:hover::before {
opacity: 1;
visibility: visible;
}
@media (max-width: 767px) {
.sidebar {
display: none;
}
}
`}</style>
</aside>
);
};
// SVG Icons as React components
function LogoIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="28" height="28" style={{ color: '#0052ff' }}>
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/>
</svg>
);
}
function ChevronIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<polyline points="15 18 9 12 15 6"/>
</svg>
);
}
function HomeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
);
}
function RecordIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
);
}
function BudgetIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
);
}
function StatisticsIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
);
}
export default Sidebar;