915339d42a
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
278 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|