Files
meijiaka-zy/tauri-app/src/components/Layout/Sidebar.tsx
T
小鱼开发 915339d42a release: bump version to 1.6.1
Frontend fixes:
- fix(VideoCompose): clear step dirty flag after compose success
- refactor(MyWorks): play product videos directly without transcode cache
- feat(CoverDesign): swap main/subtitle positions in cover preview
- fix(SubtitleBurning): charge points after burn success instead of before
- fix(VoiceSynthesis/VideoGeneration/SubtitleBurning/CoverDesign): mark downstream steps dirty on re-generation
- fix(MyWorks): bind video event listeners after async videoUrl load
- fix(CoverDesign): revoke Blob URLs on upload/unmount to prevent memory leak
2026-05-25 22:35:35 +08:00

278 lines
8.8 KiB
TypeScript

import { useState, useRef, useEffect } from 'react';
import { createNewProject, useAuthStore } from '../../store';
import { usePointStore } from '../../store';
import { useNavigation } from '../../contexts/NavigationContext';
import './Sidebar.css';
interface NavItem {
id: string;
label: string;
icon: string;
children?: { id: string; label: string }[];
}
const navItems: NavItem[] = [
{
id: 'video-creation',
label: '视频创作',
icon: 'M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z',
},
{
id: 'content-management',
label: '内容管理',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
children: [
{ id: 'voice-material', label: '声音复刻' },
{ id: 'cover-avatar', label: '封面形象' },
{ id: 'my-works', label: '我的作品' },
],
},
];
interface SidebarProps {
currentPath: string;
onNavigate: (path: string) => void;
}
const userMenuItems = [
{ id: 'profile', label: '我的账户' },
{ id: 'settings', label: '设置' },
];
export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
const { appEnvironment } = useNavigation();
const authUser = useAuthStore((s) => s.user);
const balance = usePointStore((s) => s.balance);
const fetchBalance = usePointStore((s) => s.fetchBalance);
const [expandedItems, setExpandedItems] = useState<Set<string>>(
new Set(['content-management'])
);
const [showUserMenu, setShowUserMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// 组件挂载时拉取一次余额
useEffect(() => {
fetchBalance().catch(() => {});
}, [fetchBalance]);
// 点击外部关闭用户菜单
useEffect(() => {
if (!showUserMenu) return;
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowUserMenu(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [showUserMenu]);
const toggleExpand = (id: string) => {
setExpandedItems(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
};
const handleClick = (item: NavItem) => {
if (item.children) {
toggleExpand(item.id);
} else if (item.id === 'recharge') {
window.dispatchEvent(new CustomEvent('open-recharge-modal'));
onNavigate('profile');
} else {
onNavigate(item.id);
}
};
const handleNewProject = async (e: React.MouseEvent) => {
e.stopPropagation();
await createNewProject();
onNavigate('video-creation');
};
const isActive = (id: string) => currentPath === id;
return (
<aside className="sidebar">
<div className="sidebar-header">
<div className="sidebar-logo">
<img
src="/assets/logo.png"
alt="美家卡智影"
style={{ width: 28, height: 24, objectFit: 'contain' }}
/>
<span className="sidebar-title"></span>
</div>
</div>
<nav className="sidebar-nav">
{navItems.map(item => (
<div key={item.id} className="nav-group">
<button
className={`nav-item ${isActive(item.id) ? 'active' : ''} ${
item.children && item.children.some(c => isActive(c.id)) ? 'child-active' : ''
}`}
onClick={() => handleClick(item)}
>
<svg
className="nav-icon"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d={item.icon} />
</svg>
<span className="nav-label">{item.label}</span>
{item.id === 'video-creation' && (
<span
className="nav-new-project"
onClick={handleNewProject}
title="新建项目"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleNewProject(e as unknown as React.MouseEvent);
}
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
</span>
)}
{item.children && (
<svg
className={`nav-chevron ${expandedItems.has(item.id) ? 'expanded' : ''}`}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
)}
</button>
{item.children && expandedItems.has(item.id) && (
<div className="nav-children">
{item.children.map(child => (
<button
key={child.id}
className={`nav-child-item ${isActive(child.id) ? 'active' : ''}`}
onClick={() => onNavigate(child.id)}
>
<span>{child.label}</span>
</button>
))}
</div>
)}
</div>
))}
</nav>
{appEnvironment !== 'production' && (
<div
style={{
margin: '0 var(--spacing-md) var(--spacing-sm)',
padding: 'var(--spacing-xs) var(--spacing-sm)',
background: 'var(--color-warning)',
color: '#fff',
borderRadius: '6px',
fontSize: '12px',
fontWeight: 600,
textAlign: 'center',
letterSpacing: '0.5px',
}}
>
{appEnvironment === 'staging' ? '测试环境' : appEnvironment === 'development' ? '开发环境' : appEnvironment}
</div>
)}
<div className="sidebar-footer" ref={menuRef}>
<div className="sidebar-footer-card">
<div
className="sidebar-user"
onClick={() => setShowUserMenu(!showUserMenu)}
title="菜单"
>
<img
src={authUser?.avatar || '/default-avatar.svg'}
alt="avatar"
className="user-avatar"
style={{ objectFit: 'cover' }}
/>
<div className="user-info">
<span className="user-name">{authUser?.nickname || '用户'}</span>
<span className="sidebar-balance-text">{balance} </span>
</div>
<svg
className={`user-chevron ${showUserMenu ? 'expanded' : ''}`}
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</div>
</div>
{showUserMenu && (
<div className="user-dropdown-menu">
{userMenuItems.map((item) => (
<button
key={item.id}
className={`user-dropdown-item ${currentPath === item.id ? 'active' : ''}`}
onClick={() => {
setShowUserMenu(false);
onNavigate(item.id);
}}
>
{item.label}
</button>
))}
<div className="user-dropdown-divider" />
<button
className="user-dropdown-item user-dropdown-danger"
onClick={() => {
setShowUserMenu(false);
onNavigate('logout');
}}
>
退
</button>
</div>
)}
</div>
</aside>
);
}