Merge branch 'main' of gitee.com:wg123/video-gen into main

This commit is contained in:
18610128193
2026-06-16 10:39:25 +08:00
11 changed files with 163 additions and 78 deletions
+4 -1
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VideoGen.AI 管理后台</title> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+27
View File
@@ -0,0 +1,27 @@
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
:root {
--font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--nav-bg: #08080c;
--nav-surface: rgba(255,255,255,0.04);
--nav-border: rgba(255,255,255,0.06);
--nav-text: #e8e8ec;
--nav-text-muted: #8b8fa3;
--nav-hover: rgba(255,255,255,0.08);
--nav-active: rgba(255,255,255,0.12);
}
*,
*::before,
*::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #f8f9fc;
color: #1a1a2e;
letter-spacing: -0.01em;
}
#root { min-height: 100vh; }
+1
View File
@@ -1,4 +1,5 @@
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App' import App from './App'
createRoot(document.getElementById('root')!).render(<App />) createRoot(document.getElementById('root')!).render(<App />)
+13 -5
View File
@@ -181,26 +181,28 @@ const AdminLayout: React.FC = () => {
collapsed={collapsed} collapsed={collapsed}
onCollapse={setCollapsed} onCollapse={setCollapsed}
width={220} width={220}
theme="dark" theme="light"
style={{ style={{
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)', background: '#ffffff',
borderRight: '1px solid #e5e7eb',
}} }}
> >
{/* Logo */} {/* Logo */}
<div style={{ <div style={{
height: 64, display: 'flex', alignItems: 'center', height: 64, display: 'flex', alignItems: 'center',
justifyContent: 'center', gap: 10, justifyContent: 'center', gap: 10,
borderBottom: '1px solid rgba(255,255,255,0.06)', borderBottom: '1px solid #e5e7eb',
}}> }}>
<div style={{ <div style={{
width: 32, height: 32, borderRadius: 8, width: 32, height: 32, borderRadius: 8,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 2px 8px rgba(99,102,241,0.3)',
}}> }}>
<ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} /> <ThunderboltOutlined style={{ fontSize: 16, color: '#fff' }} />
</div> </div>
{!collapsed && ( {!collapsed && (
<span style={{ color: '#f1f5f9', fontSize: 15, fontWeight: 700 }}> <span style={{ color: '#1f2937', fontSize: 15, fontWeight: 600, letterSpacing: -0.02 }}>
</span> </span>
)} )}
@@ -212,12 +214,17 @@ const AdminLayout: React.FC = () => {
selectedKeys={[activeKey]} selectedKeys={[activeKey]}
defaultOpenKeys={openKeys} defaultOpenKeys={openKeys}
items={antMenuItems} items={antMenuItems}
style={{
background: 'transparent',
border: 'none',
paddingTop: 8,
}}
onClick={({ key }) => { onClick={({ key }) => {
if (key.startsWith('group-')) return; if (key.startsWith('group-')) return;
navigate(key); navigate(key);
}} }}
style={{ background: 'transparent', borderRight: 0, marginTop: 8 }} style={{ background: 'transparent', borderRight: 0, marginTop: 8 }}
theme="dark" theme="light"
/> />
</Sider> </Sider>
@@ -228,6 +235,7 @@ const AdminLayout: React.FC = () => {
height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5', height: 56, background: '#fff', borderBottom: '1px solid #f0f0f5',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 24px', padding: '0 24px',
fontFamily: 'var(--font-sans)',
}}> }}>
<Typography.Text strong style={{ fontSize: 16 }}> <Typography.Text strong style={{ fontSize: 16 }}>
{antMenuItems.find(m => m.key === activeKey)?.label {antMenuItems.find(m => m.key === activeKey)?.label
+13 -8
View File
@@ -80,7 +80,7 @@ router = APIRouter(prefix="/admin", tags=["admin"])
@router.get("/users") @router.get("/users")
async def list_users( async def list_users(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=1000),
search: str = Query(""), search: str = Query(""),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -248,7 +248,7 @@ async def admin_change_password(
@router.get("/credit-records") @router.get("/credit-records")
async def list_credit_records( async def list_credit_records(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=500),
user_id: str | None = Query(None), user_id: str | None = Query(None),
user_name: str | None = Query(None), user_name: str | None = Query(None),
type: str | None = Query(None), type: str | None = Query(None),
@@ -315,18 +315,23 @@ async def list_credit_records(
@router.get("/notifications") @router.get("/notifications")
async def list_admin_notifications( async def list_admin_notifications(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=500),
user_id: str | None = Query(None), user_id: str | None = Query(None),
is_read: bool = Query(None),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
"""List all notifications (including broadcasts) with optional user_id filter.""" """List all notifications with optional filters."""
query = select(Notification).order_by(Notification.created_at.desc()) query = select(Notification).order_by(Notification.created_at.desc())
count_query = select(func.count(Notification.id)) count_query = select(func.count(Notification.id))
if user_id: if user_id:
query = query.where(Notification.user_id == user_id) query = query.where(Notification.user_id == user_id)
count_query = count_query.where(Notification.user_id == user_id) count_query = count_query.where(Notification.user_id == user_id)
if is_read is not None:
query = query.where(Notification.is_read == is_read)
count_query = count_query.where(Notification.is_read == is_read)
total = (await db.execute(count_query)).scalar() or 0 total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
@@ -573,7 +578,7 @@ async def get_payment_stats(
@router.get("/payment-orders") @router.get("/payment-orders")
async def list_payment_orders( async def list_payment_orders(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=500),
payment_method: str | None = Query(None), payment_method: str | None = Query(None),
status: str | None = Query(None), status: str | None = Query(None),
start_date: str | None = Query(None), start_date: str | None = Query(None),
@@ -1086,7 +1091,7 @@ async def update_system_config(
@router.get("/operation-logs") @router.get("/operation-logs")
async def list_operation_logs( async def list_operation_logs(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -1279,7 +1284,7 @@ async def get_stats(
@router.get("/token-usage") @router.get("/token-usage")
async def list_token_usage( async def list_token_usage(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=500),
user_id: str | None = Query(None), user_id: str | None = Query(None),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -1321,7 +1326,7 @@ async def admin_list_generation_records(
user_id: str | None = Query(None), user_id: str | None = Query(None),
status: str | None = Query(None), status: str | None = Query(None),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100), page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user), admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
+2
View File
@@ -22,3 +22,5 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*.bak
*.zip
Binary file not shown.
+4 -1
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>video-gen-app</title> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>民众智创</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -119,10 +119,10 @@ const EXPANDED_W = 240;
const COLLAPSED_W = 68; const COLLAPSED_W = 68;
const GRADIENTS = [ const GRADIENTS = [
{ gradient: 'linear-gradient(135deg, #f59e0b, #f97316)', shadow: 'rgba(245,158,11,0.3)', icon: <StarFilled /> }, { gradient: 'linear-gradient(135deg, #c9a96e, #a67c52)', shadow: 'rgba(201,169,110,0.25)', icon: <StarFilled /> },
{ gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)', shadow: 'rgba(99,102,241,0.3)', icon: <FireFilled /> }, { gradient: 'linear-gradient(135deg, #4a5568, #2d3748)', shadow: 'rgba(74,85,104,0.25)', icon: <FireFilled /> },
{ gradient: 'linear-gradient(135deg, #06b6d4, #0ea5e9)', shadow: 'rgba(6,182,212,0.3)', icon: <CrownFilled /> }, { gradient: 'linear-gradient(135deg, #718096, #4a5568)', shadow: 'rgba(113,128,150,0.25)', icon: <CrownFilled /> },
{ gradient: 'linear-gradient(135deg, #10b981, #059669)', shadow: 'rgba(16,185,129,0.3)', icon: <BankFilled /> }, { gradient: 'linear-gradient(135deg, #5a67d8, #434190)', shadow: 'rgba(90,103,216,0.25)', icon: <BankFilled /> },
]; ];
const AppLayout: React.FC = () => { const AppLayout: React.FC = () => {
@@ -139,8 +139,15 @@ const AppLayout: React.FC = () => {
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]); const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({}); const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [siteName, setSiteName] = useState('VideoGen.AI'); const [siteName, setSiteName] = useState(() => {
const [siteLogo, setSiteLogo] = useState(''); const cached = localStorage.getItem('siteInfo');
return cached ? JSON.parse(cached).siteName || '' : '';
});
const [siteLogo, setSiteLogo] = useState(() => {
const cached = localStorage.getItem('siteInfo');
return cached ? JSON.parse(cached).siteLogo || '' : '';
});
const [siteInfoLoading, setSiteInfoLoading] = useState(!localStorage.getItem('siteInfo'));
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false); const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null); const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
const [paymentMethod, setPaymentMethod] = useState<string>('alipay'); const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
@@ -164,11 +171,29 @@ const AppLayout: React.FC = () => {
}, []); }, []);
useEffect(() => { useEffect(() => {
const cached = localStorage.getItem('siteInfo');
if (!cached) {
setSiteName('民众智创');
setSiteLogo('');
document.title = '民众智创';
}
getSiteInfo().then(info => { getSiteInfo().then(info => {
setSiteName(info.siteName || 'VideoGen.AI'); const siteName = info.siteName || '民众智创';
setSiteLogo(info.siteLogo || ''); const siteLogo = info.siteLogo || '';
document.title = info.siteName || 'VideoGen.AI'; setSiteName(siteName);
}).catch(() => {}); setSiteLogo(siteLogo);
document.title = siteName;
localStorage.setItem('siteInfo', JSON.stringify({ siteName, siteLogo }));
}).catch(() => {
// 如果没有缓存且请求失败,使用默认值
if (!cached) {
setSiteName('民众智创');
document.title = '民众智创';
}
}).finally(() => {
setSiteInfoLoading(false);
});
}, []); }, []);
const loadUnreadCount = () => { const loadUnreadCount = () => {
@@ -264,7 +289,7 @@ const AppLayout: React.FC = () => {
const userMenuItems = [ const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true }, { key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
{ key: 'credits', icon: <WalletOutlined />, label: `可用积分: ${user?.credits ?? 0}`, disabled: true }, { key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
{ type: 'divider' as const }, { type: 'divider' as const },
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' }, { key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' }, { key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
@@ -369,8 +394,8 @@ const AppLayout: React.FC = () => {
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<div className="desktop-sidebar" style={{ <div className="desktop-sidebar" style={{
width: sidebarW, position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100, width: sidebarW, position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)', background: 'var(--nav-bg)',
borderRight: '1px solid rgba(255,255,255,0.05)', borderRight: '1px solid var(--nav-border)',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
transition: 'width 0.25s ease', transition: 'width 0.25s ease',
overflow: 'hidden', overflow: 'hidden',
@@ -379,26 +404,26 @@ const AppLayout: React.FC = () => {
<div style={{ <div style={{
height: 72, display: 'flex', alignItems: 'center', height: 72, display: 'flex', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between', justifyContent: collapsed ? 'center' : 'space-between',
borderBottom: '1px solid rgba(255,255,255,0.06)', borderBottom: '1px solid #e5e7eb',
padding: collapsed ? '0' : '0 16px 0 20px', padding: collapsed ? '0' : '0 16px 0 20px',
flexShrink: 0, flexShrink: 0,
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, overflow: 'hidden' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12, overflow: 'hidden' }}>
<div style={{ <div style={{
width: 36, height: 36, borderRadius: 10, flexShrink: 0, width: 38, height: 38, borderRadius: 12, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 12px rgba(99,102,241,0.3)', overflow: 'hidden', boxShadow: '0 2px 12px rgba(99,102,241,0.3)', overflow: 'hidden',
}}> }}>
{siteLogo ? ( {siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} /> <img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
) : ( ) : (
<ThunderboltOutlined style={{ fontSize: 18, color: '#fff' }} /> <ThunderboltOutlined style={{ fontSize: 18, color: '#1f2937' }} />
)} )}
</div> </div>
{!collapsed && ( {!collapsed && (
<span style={{ <span style={{
color: '#f1f5f9', fontSize: 17, fontWeight: 800, letterSpacing: -0.5, color: 'var(--nav-text)', fontSize: 16, fontWeight: 600, letterSpacing: -0.02,
whiteSpace: 'nowrap', opacity: collapsed ? 0 : 1, whiteSpace: 'nowrap', opacity: collapsed ? 0 : 1,
transition: 'opacity 0.2s ease', transition: 'opacity 0.2s ease',
}}> }}>
@@ -411,7 +436,7 @@ const AppLayout: React.FC = () => {
{/* Menu */} {/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}> <div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{/* {!collapsed && ( {/* {!collapsed && (
<div style={{ color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}> <div style={{ color: 'rgba(0,0,0,0.3)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
导航 导航
</div> </div>
)} */} )} */}
@@ -434,13 +459,12 @@ const AppLayout: React.FC = () => {
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{ <div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
display: 'flex', alignItems: 'center', display: 'flex', alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start', justifyContent: collapsed ? 'center' : 'flex-start',
gap: collapsed ? 0 : 10, gap: collapsed ? 0 : 12,
padding: collapsed ? '10px 0' : depth > 0 ? '8px 14px 8px 32px' : '10px 14px', padding: collapsed ? '10px 0' : depth > 0 ? '7px 14px 7px 32px' : '9px 16px',
borderRadius: 10, margin: '2px 0', cursor: 'pointer', borderRadius: 8, margin: '1px 0', cursor: 'pointer',
fontSize: depth > 0 ? 12 : 13, fontWeight: isActive ? 600 : 400, fontSize: depth > 0 ? 12 : 13, fontWeight: isActive ? 500 : 400,
color: isActive ? '#fff' : 'rgba(148,163,184,0.75)', color: isActive ? 'var(--nav-text)' : 'var(--nav-text-muted)',
background: isActive ? 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))' : 'transparent', background: isActive ? 'var(--nav-active)' : 'transparent',
border: isActive ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
}}> }}>
<span style={{ fontSize: depth > 0 ? 14 : 16, flexShrink: 0 }}>{menuIcon}</span> <span style={{ fontSize: depth > 0 ? 14 : 16, flexShrink: 0 }}>{menuIcon}</span>
@@ -457,8 +481,8 @@ const AppLayout: React.FC = () => {
items.push( items.push(
<div key={g.id}> <div key={g.id}>
<div style={{ <div style={{
color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600, color: 'var(--nav-text-muted)', fontSize: 10, fontWeight: 500,
padding: '8px 12px 6px', letterSpacing: 1, padding: '8px 16px 6px', letterSpacing: 0.05, textTransform: 'uppercase',
}}> }}>
{g.label} {g.label}
</div> </div>
@@ -483,23 +507,23 @@ const AppLayout: React.FC = () => {
<div onClick={() => setRechargeModalOpen(true)} style={{ <div onClick={() => setRechargeModalOpen(true)} style={{
margin: collapsed ? '4px auto' : '4px 14px', margin: collapsed ? '4px auto' : '4px 14px',
padding: collapsed ? '10px' : '10px 14px', padding: collapsed ? '10px' : '10px 14px',
borderRadius: 12, cursor: 'pointer', borderRadius: 10, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start', display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
gap: collapsed ? 0 : 10, gap: collapsed ? 0 : 10,
color: '#818cf8', fontSize: 13, fontWeight: 600, color: '#c9a96e', fontSize: 13, fontWeight: 500,
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))', background: 'rgba(201,169,110,0.08)',
border: '1px solid rgba(99,102,241,0.25)', border: '1px solid rgba(201,169,110,0.15)',
transition: 'all 0.2s', transition: 'all 0.2s',
}} }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.3), rgba(139,92,246,0.3))'; }} onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(201,169,110,0.15)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))'; }} onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(201,169,110,0.08)'; }}
> >
<PlusOutlined style={{ fontSize: 14 }} /> <PlusOutlined style={{ fontSize: 14 }} />
{!collapsed && <span></span>} {!collapsed && <span></span>}
</div> </div>
{/* User block at bottom-left */} {/* User block at bottom-left */}
<div style={{ padding: collapsed ? '12px 8px' : '14px 14px', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}> <div style={{ padding: collapsed ? '12px 8px' : '14px 14px', borderTop: '1px solid #e5e7eb', flexShrink: 0 }}>
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow> <Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
<div style={{ <div style={{
display: 'flex', alignItems: 'center', display: 'flex', alignItems: 'center',
@@ -507,19 +531,19 @@ const AppLayout: React.FC = () => {
gap: collapsed ? 0 : 10, gap: collapsed ? 0 : 10,
padding: collapsed ? '8px' : '8px 10px', padding: collapsed ? '8px' : '8px 10px',
borderRadius: 12, cursor: 'pointer', transition: 'background 0.2s', borderRadius: 12, cursor: 'pointer', transition: 'background 0.2s',
background: 'rgba(255,255,255,0.03)', background: 'rgba(0,0,0,0.02)',
}} }}
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.06)'} onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.04)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.03)'} onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(0,0,0,0.02)'}
> >
<Avatar size={32} icon={<UserOutlined />} <Avatar size={32} icon={<UserOutlined />}
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', flexShrink: 0 }} /> style={{ background: 'linear-gradient(135deg, #4a5568, #2d3748)', flexShrink: 0 }} />
{!collapsed && ( {!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#e2e8f0', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> <div style={{ color: 'var(--nav-text)', fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
{user?.username} {user?.username}
</div> </div>
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 11 }}>{user?.credits || 0}</div> <div style={{ color: 'var(--nav-text-muted)', fontSize: 11, letterSpacing: 0 }}>: {user?.credits || 0}</div>
</div> </div>
)} )}
</div> </div>
@@ -543,7 +567,7 @@ const AppLayout: React.FC = () => {
position: 'absolute', left: '50%', top: '50%', position: 'absolute', left: '50%', top: '50%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
width: 24, height: 56, borderRadius: '0 8px 8px 0', width: 24, height: 56, borderRadius: '0 8px 8px 0',
background: '#1a1a35', border: '1px solid rgba(255,255,255,0.1)', borderLeft: 'none', background: '#f8f9fb', border: '1px solid #e5e7eb', borderLeft: 'none',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
boxShadow: '2px 0 12px rgba(0,0,0,0.2)', boxShadow: '2px 0 12px rgba(0,0,0,0.2)',
animation: 'fadeInRight 0.2s ease both', animation: 'fadeInRight 0.2s ease both',
@@ -557,7 +581,7 @@ const AppLayout: React.FC = () => {
{/* Main Content */} {/* Main Content */}
<div className="desktop-content" style={{ <div className="desktop-content" style={{
marginLeft: sidebarW, flex: 1, minHeight: '100vh', background: '#f5f6fa', marginLeft: sidebarW, flex: 1, minHeight: '100vh', background: '#f8f9fc',
padding: '24px 32px 32px', transition: 'margin-left 0.25s ease', padding: '24px 32px 32px', transition: 'margin-left 0.25s ease',
}}> }}>
<Outlet /> <Outlet />
@@ -618,8 +642,8 @@ const AppLayout: React.FC = () => {
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<WalletOutlined style={{ color: '#6366f1' }} /> <WalletOutlined style={{ color: '#6366f1' }} />
<Typography.Text style={{ color: '#64748b' }}></Typography.Text> <Typography.Text style={{ color: '#64748b', letterSpacing: 0 }}></Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text> <Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{user?.credits ?? 0}</Typography.Text>
</Space> </Space>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{rechargeOptions.map((opt, idx) => { {rechargeOptions.map((opt, idx) => {
@@ -27,11 +27,10 @@ const NotificationPopup: React.FC = () => {
const fetchNotifications = useCallback(async () => { const fetchNotifications = useCallback(async () => {
try { try {
const result = await getNotifications(); const result = await getNotifications(1, 20, false);
const data = result.items || []; const data = result.items || [];
const unread = data.filter((n: Notification) => !n.isRead); setNotifications(data);
setNotifications(unread); if (data.length > 0 && !visible) {
if (unread.length > 0 && !visible) {
setVisible(true); setVisible(true);
setCurrentIndex(0); setCurrentIndex(0);
} }
+26 -13
View File
@@ -1,4 +1,15 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap');
:root {
--font-sans: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--nav-bg: #f8f9fb;
--nav-surface: rgba(0,0,0,0.02);
--nav-border: #e5e7eb;
--nav-text: #1f2937;
--nav-text-muted: #6b7280;
--nav-hover: rgba(0,0,0,0.04);
--nav-active: rgba(99,102,241,0.08);
}
*, *,
*::before, *::before,
@@ -6,11 +17,12 @@
html, body { html, body {
margin: 0; padding: 0; margin: 0; padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
background: #f5f6fa; background: #f8f9fc;
color: #1a1a2e; color: #1a1a2e;
letter-spacing: -0.01em;
} }
#root { min-height: 100vh; } #root { min-height: 100vh; }
@@ -120,26 +132,27 @@ html, body {
.mobile-bottom-nav { .mobile-bottom-nav {
display: none; display: none;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 200; position: fixed; bottom: 0; left: 0; right: 0; z-index: 200;
height: 64px; height: 60px;
background: rgba(255,255,255,0.92); background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px); backdrop-filter: blur(24px);
border-top: 1px solid rgba(0,0,0,0.06); border-top: 1px solid rgba(0,0,0,0.05);
box-shadow: 0 -2px 12px rgba(0,0,0,0.06); box-shadow: 0 -4px 20px rgba(0,0,0,0.06);
} }
.mobile-bottom-nav .nav-item { .mobile-bottom-nav .nav-item {
flex: 1; display: flex; flex-direction: column; flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 3px; align-items: center; justify-content: center; gap: 2px;
cursor: pointer; transition: all 0.2s; cursor: pointer; transition: all 0.2s;
color: #94a3b8; font-size: 10px; font-weight: 500; color: #8b8fa3; font-size: 9px; font-weight: 400;
letter-spacing: 0;
} }
.mobile-bottom-nav .nav-item.active { .mobile-bottom-nav .nav-item.active {
color: #6366f1; color: #08080c;
} }
.mobile-bottom-nav .nav-item .nav-icon { .mobile-bottom-nav .nav-item .nav-icon {
font-size: 20px; transition: transform 0.2s; font-size: 18px; transition: transform 0.2s;
} }
.mobile-bottom-nav .nav-item.active .nav-icon { .mobile-bottom-nav .nav-item.active .nav-icon {
transform: scale(1.1); transform: scale(1.05);
} }
/* ── Responsive Breakpoints ────────────── */ /* ── Responsive Breakpoints ────────────── */