gcx/frontend/admin-web/src/layouts/AdminLayout.tsx

374 lines
13 KiB
TypeScript

'use client';
import React, { useState } from 'react';
import { usePathname, useRouter } from 'next/navigation';
import Link from 'next/link';
import { t } from '@/i18n/locales';
import { useAuth } from '@/lib/auth-context';
/**
* D. Web管理前端 - 主布局
*
* 左侧Sidebar导航 + 顶部Header + 主内容区
* 覆盖 D1-D8 所有管理模块的路由结构
*/
interface NavItem {
key: string;
icon: string;
label: string;
children?: NavItem[];
badge?: number;
}
const navItems: NavItem[] = [
{ key: 'dashboard', icon: '📊', label: t('nav_dashboard') },
{
key: 'issuers', icon: '🏢', label: t('nav_issuers'),
children: [
{ key: 'issuers/review', icon: '', label: t('nav_issuers_review') },
{ key: 'issuers/list', icon: '', label: t('nav_issuers_list') },
{ key: 'issuers/coupons', icon: '', label: t('nav_issuers_coupons') },
],
},
{
key: 'users', icon: '👤', label: t('nav_users'),
children: [
{ key: 'users/list', icon: '', label: t('nav_users_list') },
{ key: 'users/kyc', icon: '', label: t('nav_users_kyc'), badge: 5 },
{ key: 'users/sms-logs', icon: '', label: t('nav_users_sms_logs') },
],
},
{
key: 'trading', icon: '💱', label: t('nav_trading'),
children: [
{ key: 'trading/realtime', icon: '', label: t('nav_trading_realtime') },
{ key: 'trading/stats', icon: '', label: t('nav_trading_stats') },
{ key: 'trading/orders', icon: '', label: t('nav_trading_orders') },
],
},
{
key: 'risk', icon: '🛡️', label: t('nav_risk'),
children: [
{ key: 'risk/dashboard', icon: '', label: t('nav_risk_dashboard'), badge: 3 },
{ key: 'risk/suspicious', icon: '', label: t('nav_risk_suspicious') },
{ key: 'risk/blacklist', icon: '', label: t('nav_risk_blacklist') },
{ key: 'risk/ofac', icon: '', label: t('nav_risk_ofac') },
],
},
{
key: 'compliance', icon: '📋', label: t('nav_compliance'),
children: [
{ key: 'compliance/sar', icon: '', label: t('nav_compliance_sar') },
{ key: 'compliance/ctr', icon: '', label: t('nav_compliance_ctr') },
{ key: 'compliance/audit', icon: '', label: t('nav_compliance_audit') },
{ key: 'compliance/reports', icon: '', label: t('nav_compliance_reports') },
],
},
{ key: 'app-versions', icon: '📱', label: t('nav_app_versions') },
{
key: 'system', icon: '⚙️', label: t('nav_system'),
children: [
{ key: 'system/admins', icon: '', label: t('nav_system_admins') },
{ key: 'system/config', icon: '', label: t('nav_system_config') },
{ key: 'system/contracts', icon: '', label: t('nav_system_contracts') },
{ key: 'system/monitor', icon: '', label: t('nav_system_monitor') },
],
},
{ key: 'disputes', icon: '⚖️', label: t('nav_disputes'), badge: 8 },
{ key: 'coupons', icon: '🎫', label: t('nav_coupons_mgmt') },
{ key: 'finance', icon: '💰', label: t('nav_finance') },
{ key: 'chain', icon: '⛓️', label: t('nav_chain') },
{ key: 'reports', icon: '📈', label: t('nav_reports') },
{ key: 'merchant', icon: '🏪', label: t('nav_merchant') },
{ key: 'agent', icon: '🤖', label: t('nav_agent') },
{ key: 'insurance', icon: '🛡️', label: t('nav_insurance') },
{
key: 'analytics', icon: '📊', label: t('nav_analytics'),
children: [
{ key: 'analytics/users', icon: '', label: t('nav_analytics_users') },
{ key: 'analytics/coupons', icon: '', label: t('nav_analytics_coupons') },
{ key: 'analytics/market-maker', icon: '', label: t('nav_analytics_market_maker') },
{ key: 'analytics/consumer-protection', icon: '', label: t('nav_analytics_consumer_protection') },
],
},
];
export const AdminLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const pathname = usePathname();
const router = useRouter();
const { isAuthenticated, isLoading, user, logout } = useAuth();
const [collapsed, setCollapsed] = useState(false);
// 未登录 → /login
if (!isLoading && !isAuthenticated) {
router.replace('/login');
return null;
}
// 加载中显示空白
if (isLoading) return null;
// Derive activeKey from current pathname
const activeKey = pathname.replace(/^\//, '') || 'dashboard';
// Auto-expand parent nav items based on current path
const getInitialExpanded = () => {
const expanded: string[] = [];
navItems.forEach(item => {
if (item.children && item.children.some(c => activeKey.startsWith(c.key) || activeKey === item.key)) {
expanded.push(item.key);
}
});
return expanded;
};
const [expandedKeys, setExpandedKeys] = useState<string[]>(getInitialExpanded);
const toggleExpand = (key: string) => {
setExpandedKeys(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
return (
<div style={{ display: 'flex', height: '100vh', background: 'var(--color-bg)' }}>
{/* Sidebar */}
<aside
style={{
width: collapsed ? 'var(--sidebar-collapsed-width)' : 'var(--sidebar-width)',
background: 'var(--color-surface)',
borderRight: '1px solid var(--color-border-light)',
display: 'flex',
flexDirection: 'column',
transition: 'width 0.2s ease',
overflow: 'hidden',
}}
>
{/* Logo */}
<div style={{
height: 'var(--header-height)',
display: 'flex',
alignItems: 'center',
padding: '0 20px',
borderBottom: '1px solid var(--color-border-light)',
}}>
<img
src="/logo_icon.png"
alt="Genex"
style={{ width: 32, height: 32, borderRadius: 'var(--radius-sm)' }}
/>
{!collapsed && (
<span style={{
marginLeft: 12,
font: 'var(--text-h3)',
color: 'var(--color-primary)',
}}>
Genex Admin
</span>
)}
</div>
{/* Nav */}
<nav style={{ flex: 1, overflow: 'auto', padding: '12px 8px' }}>
{navItems.map(item => (
<div key={item.key}>
<button
onClick={() => {
if (item.children) {
toggleExpand(item.key);
} else {
router.push('/' + item.key);
}
}}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '10px 12px',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: activeKey === item.key || activeKey.startsWith(item.key + '/') ? 'var(--color-primary-surface)' : 'transparent',
color: activeKey === item.key || activeKey.startsWith(item.key + '/') ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
font: 'var(--text-label)',
marginBottom: 2,
textAlign: 'left',
}}
>
<span style={{ fontSize: 18, width: 24 }}>{item.icon}</span>
{!collapsed && (
<>
<span style={{ flex: 1, marginLeft: 8 }}>{item.label}</span>
{item.badge && (
<span style={{
background: 'var(--color-error)',
color: 'white',
borderRadius: 'var(--radius-full)',
padding: '1px 6px',
fontSize: 10,
fontWeight: 600,
}}>
{item.badge}
</span>
)}
{item.children && (
<span style={{ fontSize: 12, transform: expandedKeys.includes(item.key) ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s' }}>
</span>
)}
</>
)}
</button>
{/* Sub items */}
{item.children && expandedKeys.includes(item.key) && !collapsed && (
<div style={{ marginLeft: 36, marginBottom: 4 }}>
{item.children.map(sub => (
<button
key={sub.key}
onClick={() => router.push('/' + sub.key)}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
padding: '8px 12px',
border: 'none',
borderRadius: 'var(--radius-sm)',
background: activeKey === sub.key ? 'var(--color-primary-surface)' : 'transparent',
color: activeKey === sub.key ? 'var(--color-primary)' : 'var(--color-text-tertiary)',
cursor: 'pointer',
font: 'var(--text-body-sm)',
textAlign: 'left',
}}
>
<span style={{ flex: 1 }}>{sub.label}</span>
{sub.badge && (
<span style={{
background: 'var(--color-error)',
color: 'white',
borderRadius: 'var(--radius-full)',
padding: '1px 6px',
fontSize: 10,
}}>
{sub.badge}
</span>
)}
</button>
))}
</div>
)}
</div>
))}
</nav>
{/* Collapse toggle */}
<button
onClick={() => setCollapsed(!collapsed)}
style={{
padding: 12,
border: 'none',
borderTop: '1px solid var(--color-border-light)',
background: 'none',
cursor: 'pointer',
color: 'var(--color-text-tertiary)',
}}
>
{collapsed ? '→' : `${t('collapse')}`}
</button>
</aside>
{/* Main Content */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Header */}
<header style={{
height: 'var(--header-height)',
background: 'var(--color-surface)',
borderBottom: '1px solid var(--color-border-light)',
display: 'flex',
alignItems: 'center',
padding: '0 24px',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<input
placeholder={t('header_search_placeholder')}
style={{
width: 320,
height: 36,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-full)',
padding: '0 16px',
font: 'var(--text-body)',
background: 'var(--color-gray-50)',
outline: 'none',
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
{/* AI Agent Button */}
<button style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
border: '1px solid var(--color-primary)',
borderRadius: 'var(--radius-full)',
background: 'var(--color-primary-surface)',
color: 'var(--color-primary)',
cursor: 'pointer',
font: 'var(--text-label-sm)',
}}>
{t('header_ai_assistant')}
</button>
{/* Notifications */}
<button style={{
position: 'relative',
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: 20,
}}>
🔔
<span style={{
position: 'absolute',
top: -2, right: -2,
width: 8, height: 8,
background: 'var(--color-error)',
borderRadius: '50%',
}} />
</button>
{/* Admin avatar + logout */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
cursor: 'pointer',
}}
onClick={logout}
title={t('header_logout')}
>
<div style={{
width: 32, height: 32,
borderRadius: '50%',
background: 'var(--color-primary)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontSize: 14,
fontWeight: 600,
}}>
{user?.name?.charAt(0)?.toUpperCase() || 'A'}
</div>
</div>
</div>
</header>
{/* Page Content */}
<main style={{ flex: 1, overflow: 'auto', padding: 24 }}>
{children}
</main>
</div>
</div>
);
};