1012 lines
44 KiB
TypeScript
1012 lines
44 KiB
TypeScript
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd';
|
|
import { QRCodeSVG } from 'qrcode.react';
|
|
import {
|
|
PlayCircleOutlined,
|
|
WalletOutlined,
|
|
LogoutOutlined,
|
|
UserOutlined,
|
|
ThunderboltOutlined,
|
|
HomeOutlined,
|
|
DashboardOutlined,
|
|
CodeOutlined,
|
|
LockOutlined,
|
|
PlusCircleOutlined,
|
|
LeftOutlined,
|
|
RightOutlined,
|
|
PlusOutlined,
|
|
GiftOutlined,
|
|
BellOutlined,
|
|
StarFilled,
|
|
FireFilled,
|
|
CrownFilled,
|
|
BankFilled,
|
|
CloseOutlined,
|
|
WechatOutlined,
|
|
AlipayCircleOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
|
import { useAuthStore } from '../../store/useAuthStore';
|
|
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
|
import NotificationPopup from '../NotificationPopup';
|
|
|
|
interface MenuConfig {
|
|
id: string;
|
|
key?: string;
|
|
label: string;
|
|
path: string;
|
|
icon: string;
|
|
sortOrder: number;
|
|
isActive: boolean;
|
|
parentId?: string | null;
|
|
parent_id?: string | null;
|
|
menuType?: string;
|
|
menu_type?: string;
|
|
}
|
|
|
|
const iconMap: Record<string, React.ReactNode> = {
|
|
HomeOutlined: <HomeOutlined />,
|
|
DashboardOutlined:<DashboardOutlined/>,
|
|
CodeOutlined:<CodeOutlined/>,
|
|
PlayCircleOutlined: <PlayCircleOutlined />,
|
|
WalletOutlined: <WalletOutlined />,
|
|
SettingOutlined: <LockOutlined />,
|
|
BellOutlined: <GiftOutlined />,
|
|
UserOutlined: <UserOutlined />,
|
|
AppstoreOutlined: <FireFilled />,
|
|
FileTextOutlined: <FireFilled />,
|
|
StarOutlined: <StarFilled />,
|
|
HeartOutlined: <FireFilled />,
|
|
CameraOutlined: <PlayCircleOutlined />,
|
|
};
|
|
|
|
const EXPANDED_W = 240;
|
|
const COLLAPSED_W = 68;
|
|
|
|
const GRADIENTS = [
|
|
{ gradient: 'linear-gradient(135deg, #f59e0b, #f97316)', shadow: 'rgba(245,158,11,0.3)', icon: <StarFilled /> },
|
|
{ gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)', shadow: 'rgba(99,102,241,0.3)', icon: <FireFilled /> },
|
|
{ gradient: 'linear-gradient(135deg, #06b6d4, #0ea5e9)', shadow: 'rgba(6,182,212,0.3)', icon: <CrownFilled /> },
|
|
{ gradient: 'linear-gradient(135deg, #10b981, #059669)', shadow: 'rgba(16,185,129,0.3)', icon: <BankFilled /> },
|
|
];
|
|
|
|
const AppLayout: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { user, logout } = useAuthStore();
|
|
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
|
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
|
|
const [pwdForm] = Form.useForm();
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
const [toggleHover, setToggleHover] = useState(false);
|
|
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
|
|
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
|
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
|
const [msgModalOpen, setMsgModalOpen] = useState(false);
|
|
const [allNotifications, setAllNotifications] = useState<any[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [siteName, setSiteName] = useState('VideoGen.AI');
|
|
const [siteLogo, setSiteLogo] = useState('');
|
|
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
|
|
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
|
|
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
|
const [paying, setPaying] = useState(false);
|
|
const [countdown, setCountdown] = useState(180); // 默认180秒超时
|
|
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const currentOrderNoRef = useRef<string | null>(null);
|
|
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
|
|
|
|
// LocalStorage keys
|
|
const PENDING_ORDER_KEY = 'pending_payment_order';
|
|
|
|
// 监听预览弹窗状态,关闭浮动按钮
|
|
useEffect(() => {
|
|
const handleModalOpen = () => {
|
|
setToggleHover(false);
|
|
};
|
|
window.addEventListener('previewOpen', handleModalOpen);
|
|
return () => window.removeEventListener('previewOpen', handleModalOpen);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
getSiteInfo().then(info => {
|
|
setSiteName(info.siteName || 'VideoGen.AI');
|
|
setSiteLogo(info.siteLogo || '');
|
|
document.title = info.siteName || 'VideoGen.AI';
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
const loadNotifications = () => {
|
|
getNotifications().then(data => {
|
|
setAllNotifications(data);
|
|
setUnreadCount(data.filter((n: any) => !(n.isRead ?? n.is_read)).length);
|
|
}).catch(() => {});
|
|
};
|
|
|
|
// 检查并恢复待处理的支付订单
|
|
useEffect(() => {
|
|
const checkPendingOrder = async () => {
|
|
const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY);
|
|
if (savedOrderStr) {
|
|
try {
|
|
const savedOrder = JSON.parse(savedOrderStr);
|
|
// 查询订单状态
|
|
const order = await getPaymentOrder(savedOrder.orderNo);
|
|
if (order.status === 'pending') {
|
|
// 订单仍然待支付,恢复弹窗
|
|
setCurrentPaymentInfo({
|
|
price: savedOrder.price,
|
|
credits: savedOrder.credits,
|
|
qrCode: savedOrder.qrCode,
|
|
method: savedOrder.method,
|
|
});
|
|
currentOrderNoRef.current = savedOrder.orderNo;
|
|
// 计算剩余时间
|
|
const now = Date.now();
|
|
const createdAt = new Date(savedOrder.createdAt).getTime();
|
|
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
|
|
const elapsedSeconds = Math.floor((now - createdAt) / 1000);
|
|
const remainingSeconds = Math.max(0, timeoutSeconds - elapsedSeconds);
|
|
|
|
if (remainingSeconds > 0) {
|
|
setQrCodeModalOpen(true);
|
|
startPolling(savedOrder.orderNo, remainingSeconds);
|
|
} else {
|
|
// 已超时,清除
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
}
|
|
} else if (order.status === 'paid') {
|
|
// 已支付
|
|
message.success('支付成功!积分已到账');
|
|
useAuthStore.getState().refreshUser();
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
} else {
|
|
// 订单已取消或其他状态,清除
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
}
|
|
} catch {
|
|
// 查询失败,清除
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
}
|
|
}
|
|
};
|
|
|
|
checkPendingOrder();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
getMenuConfigs().then(data => {
|
|
let items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
|
|
// Filter by user's allowed menus if set
|
|
if (user?.allowedMenus && user.allowedMenus.length > 0) {
|
|
const allowed = new Set(user.allowedMenus);
|
|
const groupIds = new Set<string>();
|
|
items.forEach((m: any) => {
|
|
const pid = m.parent_id ?? m.parentId;
|
|
if (pid && allowed.has(m.path)) groupIds.add(pid);
|
|
});
|
|
items = items.filter((m: any) => {
|
|
const mt = m.menu_type ?? m.menuType;
|
|
if (mt === 'group' && groupIds.has(m.id)) return true;
|
|
return allowed.has(m.path);
|
|
});
|
|
}
|
|
setMenuItems(items);
|
|
}).catch(() => {});
|
|
getRechargePackages().then(data => {
|
|
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
|
|
}).catch(() => {});
|
|
getPaymentMethods().then(data => {
|
|
setEnabledMethods(data);
|
|
// Auto-select the first enabled method
|
|
if (data.alipay) setPaymentMethod('alipay');
|
|
else if (data.wechat) setPaymentMethod('wechat');
|
|
}).catch(() => {});
|
|
loadNotifications();
|
|
}, [user]);
|
|
|
|
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
|
|
const sidebarW = collapsed ? COLLAPSED_W : EXPANDED_W;
|
|
|
|
const userMenuItems = [
|
|
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
|
|
{ key: 'credits', icon: <WalletOutlined />, label: `积分余额: ${user?.credits ?? 0}`, disabled: true },
|
|
{ type: 'divider' as const },
|
|
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
|
{ type: 'divider' as const },
|
|
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', danger: true },
|
|
];
|
|
|
|
const handleUserMenuClick = ({ key }: { key: string }) => {
|
|
if (key === 'logout') { logout(); navigate('/login'); }
|
|
else if (key === 'changePwd') { setPwdModalOpen(true); }
|
|
};
|
|
|
|
const handleChangePwd = async () => {
|
|
try {
|
|
await pwdForm.validateFields();
|
|
message.success('密码修改成功(演示)');
|
|
setPwdModalOpen(false); pwdForm.resetFields();
|
|
} catch { /* validation */ }
|
|
};
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
navigate('/login');
|
|
};
|
|
|
|
const handleMobileRecharge = () => {
|
|
setRechargeModalOpen(true);
|
|
};
|
|
|
|
const stopPolling = useCallback(() => {
|
|
if (pollingTimerRef.current) {
|
|
clearInterval(pollingTimerRef.current);
|
|
pollingTimerRef.current = null;
|
|
}
|
|
if (countdownTimerRef.current) {
|
|
clearInterval(countdownTimerRef.current);
|
|
countdownTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
|
|
stopPolling();
|
|
setCountdown(timeoutSeconds);
|
|
|
|
// 订单状态轮询(每2秒查询一次,只查询当前订单
|
|
const pollingTimer = setInterval(async () => {
|
|
try {
|
|
const order = await getPaymentOrder(orderNo);
|
|
if (order.status === 'paid') {
|
|
stopPolling();
|
|
currentOrderNoRef.current = null;
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
message.success('支付成功!积分已到账');
|
|
useAuthStore.getState().refreshUser();
|
|
setQrCodeModalOpen(false);
|
|
setCurrentPaymentInfo(null);
|
|
setSelectedPlan(null);
|
|
} else if (order.status === 'cancelled') {
|
|
stopPolling();
|
|
currentOrderNoRef.current = null;
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
}
|
|
} catch {
|
|
// ignore polling errors
|
|
}
|
|
}, 2000);
|
|
pollingTimerRef.current = pollingTimer;
|
|
|
|
// 倒计时
|
|
const countdownTimer = setInterval(() => {
|
|
setCountdown(prev => {
|
|
if (prev <= 1) {
|
|
// 超时自动取消
|
|
stopPolling();
|
|
if (currentOrderNoRef.current) {
|
|
cancelPaymentOrder(currentOrderNoRef.current).catch(() => {});
|
|
currentOrderNoRef.current = null;
|
|
}
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
message.warning('订单已超时,请重新充值');
|
|
setQrCodeModalOpen(false);
|
|
setCurrentPaymentInfo(null);
|
|
setSelectedPlan(null);
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
countdownTimerRef.current = countdownTimer;
|
|
}, [stopPolling]);
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
{/* Desktop Sidebar */}
|
|
<div className="desktop-sidebar" style={{
|
|
width: sidebarW, position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
|
|
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
|
|
borderRight: '1px solid rgba(255,255,255,0.05)',
|
|
display: 'flex', flexDirection: 'column',
|
|
transition: 'width 0.25s ease',
|
|
overflow: 'hidden',
|
|
}}>
|
|
{/* Logo + Toggle */}
|
|
<div style={{
|
|
height: 72, display: 'flex', alignItems: 'center',
|
|
justifyContent: collapsed ? 'center' : 'space-between',
|
|
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
|
padding: collapsed ? '0' : '0 16px 0 20px',
|
|
flexShrink: 0,
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, overflow: 'hidden' }}>
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
|
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
boxShadow: '0 4px 12px rgba(99,102,241,0.3)', overflow: 'hidden',
|
|
}}>
|
|
{siteLogo ? (
|
|
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
|
|
) : (
|
|
<ThunderboltOutlined style={{ fontSize: 18, color: '#fff' }} />
|
|
)}
|
|
</div>
|
|
{!collapsed && (
|
|
<span style={{
|
|
color: '#f1f5f9', fontSize: 17, fontWeight: 800, letterSpacing: -0.5,
|
|
whiteSpace: 'nowrap', opacity: collapsed ? 0 : 1,
|
|
transition: 'opacity 0.2s ease',
|
|
}}>
|
|
{siteName}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Credits pill */}
|
|
<div onClick={() => navigate('/credits')} style={{
|
|
margin: collapsed ? '16px auto 4px' : '16px 14px 4px',
|
|
padding: collapsed ? '10px' : '12px 14px',
|
|
borderRadius: 12,
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.12))',
|
|
border: '1px solid rgba(99,102,241,0.15)', cursor: 'pointer', transition: 'all 0.25s',
|
|
textAlign: collapsed ? 'center' : 'left',
|
|
}}>
|
|
{collapsed ? (
|
|
<WalletOutlined style={{ color: '#818cf8', fontSize: 18 }} />
|
|
) : (
|
|
<>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
|
<WalletOutlined style={{ color: '#818cf8', fontSize: 13 }} />
|
|
<span style={{ color: 'rgba(203,213,225,0.6)', fontSize: 12 }}>可用积分</span>
|
|
</div>
|
|
<span style={{ color: '#fff', fontSize: 22, fontWeight: 800 }}>{user?.credits ?? 0}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recharge button - opens modal */}
|
|
<div onClick={() => setRechargeModalOpen(true)} style={{
|
|
margin: collapsed ? '8px auto 4px' : '8px 14px 4px',
|
|
padding: collapsed ? '10px' : '10px 14px',
|
|
borderRadius: 12, cursor: 'pointer', textAlign: 'center',
|
|
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))',
|
|
border: '1px solid rgba(99,102,241,0.25)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
|
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))'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))'; }}
|
|
>
|
|
<PlusOutlined style={{ color: '#818cf8', fontSize: 13 }} />
|
|
{!collapsed && <span style={{ color: '#818cf8', fontSize: 13, fontWeight: 600 }}>充值积分</span>}
|
|
</div>
|
|
|
|
{/* Menu */}
|
|
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
|
{!collapsed && (
|
|
<div style={{ color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
|
|
导航
|
|
</div>
|
|
)}
|
|
{(() => {
|
|
const groups = menuItems.filter(m => (m.menu_type ?? m.menuType) === 'group');
|
|
const pages = menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group');
|
|
const childMap: Record<string, MenuConfig[]> = {};
|
|
pages.filter(m => m.parent_id ?? m.parentId).forEach(m => {
|
|
const pid = (m.parent_id ?? m.parentId) as string;
|
|
if (!childMap[pid]) childMap[pid] = [];
|
|
childMap[pid].push(m);
|
|
});
|
|
const topLevel = pages.filter(m => !(m.parent_id ?? m.parentId));
|
|
const items: React.ReactNode[] = [];
|
|
|
|
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
|
const isActive = item.path === selectedKey;
|
|
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
|
const el = (
|
|
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
|
|
display: 'flex', alignItems: 'center',
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
gap: collapsed ? 0 : 10,
|
|
padding: collapsed ? '10px 0' : depth > 0 ? '8px 14px 8px 32px' : '10px 14px',
|
|
borderRadius: 10, margin: '2px 0', cursor: 'pointer',
|
|
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
|
|
color: isActive ? '#fff' : 'rgba(148,163,184,0.75)',
|
|
background: isActive ? 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))' : 'transparent',
|
|
border: isActive ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
|
|
transition: 'all 0.2s ease',
|
|
}}>
|
|
<span style={{ fontSize: depth > 0 ? 15 : 17, flexShrink: 0 }}>{menuIcon}</span>
|
|
{!collapsed && <span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>}
|
|
</div>
|
|
);
|
|
return collapsed ? <Tooltip key={item.id} title={item.label} placement="right">{el}</Tooltip> : el;
|
|
};
|
|
|
|
// Render groups with children
|
|
groups.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(g => {
|
|
const children = (childMap[g.id] || []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
|
if (!collapsed) {
|
|
const isGroupOpen = !collapsedGroups[g.id];
|
|
items.push(
|
|
<div key={g.id}>
|
|
<div onClick={() => setCollapsedGroups(prev => ({ ...prev, [g.id]: !prev[g.id] }))} style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: '8px 14px', margin: '8px 0 2px', cursor: 'pointer',
|
|
color: 'rgba(148,163,184,0.5)', fontSize: 11, fontWeight: 600,
|
|
letterSpacing: 0.5,
|
|
}}>
|
|
<span>{g.label}</span>
|
|
<span style={{ fontSize: 10, transform: isGroupOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s' }}>▶</span>
|
|
</div>
|
|
{isGroupOpen && children.map(c => renderMenuItem(c, 1))}
|
|
</div>
|
|
);
|
|
} else {
|
|
children.forEach(c => items.push(renderMenuItem(c)));
|
|
}
|
|
});
|
|
|
|
// Render top-level pages
|
|
topLevel.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(m => {
|
|
items.push(renderMenuItem(m));
|
|
});
|
|
|
|
return items;
|
|
})()}
|
|
</div>
|
|
|
|
{/* Message button */}
|
|
<div onClick={() => { setMsgModalOpen(true); loadNotifications(); }} style={{
|
|
margin: collapsed ? '4px auto' : '4px 14px',
|
|
padding: collapsed ? '10px' : '10px 14px',
|
|
borderRadius: 12, cursor: 'pointer',
|
|
display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
|
|
gap: collapsed ? 0 : 10,
|
|
color: 'rgba(148,163,184,0.75)', fontSize: 14,
|
|
background: 'rgba(255,255,255,0.03)',
|
|
transition: 'all 0.2s',
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; }}
|
|
>
|
|
<span style={{ position: 'relative' }}>
|
|
<BellOutlined style={{ fontSize: 16 }} />
|
|
{unreadCount > 0 && (
|
|
<span style={{
|
|
position: 'absolute', top: -6, right: -8,
|
|
background: '#ef4444', color: '#fff', fontSize: 10, fontWeight: 700,
|
|
borderRadius: 10, padding: '0 5px', lineHeight: '16px', minWidth: 16, textAlign: 'center',
|
|
}}>{unreadCount > 99 ? '99+' : unreadCount}</span>
|
|
)}
|
|
</span>
|
|
{!collapsed && <span>消息中心</span>}
|
|
</div>
|
|
|
|
{/* User block at bottom-left */}
|
|
<div style={{ padding: collapsed ? '12px 8px' : '14px 14px', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
|
|
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center',
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
gap: collapsed ? 0 : 10,
|
|
padding: collapsed ? '8px' : '8px 10px',
|
|
borderRadius: 12, cursor: 'pointer', transition: 'background 0.2s',
|
|
background: 'rgba(255,255,255,0.03)',
|
|
}}
|
|
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.06)'}
|
|
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.03)'}
|
|
>
|
|
<Avatar size={32} icon={<UserOutlined />}
|
|
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', flexShrink: 0 }} />
|
|
{!collapsed && (
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ color: '#e2e8f0', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{user?.username}
|
|
</div>
|
|
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 11 }}>查看账号</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Dropdown>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Floating sidebar toggle hover zone */}
|
|
<div
|
|
className="desktop-sidebar-toggle-zone"
|
|
onMouseEnter={() => setToggleHover(true)}
|
|
onMouseLeave={() => setToggleHover(false)}
|
|
style={{
|
|
position: 'fixed', left: sidebarW - 16, top: 0, bottom: 0,
|
|
zIndex: 110, width: 32, cursor: 'default',
|
|
transition: 'left 0.25s ease',
|
|
}}
|
|
>
|
|
{toggleHover && (
|
|
<div onClick={() => setCollapsed(prev => !prev)} style={{
|
|
position: 'absolute', left: '50%', top: '50%',
|
|
transform: 'translate(-50%, -50%)',
|
|
width: 24, height: 56, borderRadius: '0 8px 8px 0',
|
|
background: '#1a1a35', border: '1px solid rgba(255,255,255,0.1)', borderLeft: 'none',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
|
boxShadow: '2px 0 12px rgba(0,0,0,0.2)',
|
|
animation: 'fadeInRight 0.2s ease both',
|
|
}}>
|
|
{collapsed
|
|
? <RightOutlined style={{ color: '#818cf8', fontSize: 12 }} />
|
|
: <LeftOutlined style={{ color: '#818cf8', fontSize: 12 }} />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="desktop-content" style={{
|
|
marginLeft: sidebarW, flex: 1, minHeight: '100vh', background: '#f5f6fa',
|
|
padding: '24px 32px 32px', transition: 'margin-left 0.25s ease',
|
|
}}>
|
|
<Outlet />
|
|
</div>
|
|
|
|
{/* Mobile Bottom Nav */}
|
|
<div className="mobile-bottom-nav">
|
|
{menuItems.map((item) => {
|
|
const isActive = item.path === selectedKey;
|
|
return (
|
|
<div key={item.id}
|
|
className={`nav-item ${isActive ? 'active' : ''}`}
|
|
onClick={() => navigate(item.path)}>
|
|
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
|
|
<span>{item.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="nav-item" onClick={handleMobileRecharge}>
|
|
<span className="nav-icon"><PlusCircleOutlined /></span>
|
|
<span>充值</span>
|
|
</div>
|
|
<div className="nav-item" onClick={handleLogout}>
|
|
<span className="nav-icon"><LogoutOutlined /></span>
|
|
<span>退出</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Change Password Modal */}
|
|
<Modal title={<Space><LockOutlined />修改密码</Space>} open={pwdModalOpen}
|
|
onOk={handleChangePwd} onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); }}
|
|
okText="确认修改" cancelText="取消" width={440}>
|
|
<Form form={pwdForm} layout="vertical" style={{ marginTop: 20 }}>
|
|
<Form.Item name="oldPwd" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
|
<Input.Password placeholder="请输入原密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
|
|
</Form.Item>
|
|
<Form.Item name="newPwd" label="新密码" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码至少6位' }]}>
|
|
<Input.Password placeholder="请输入新密码(至少6位)" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
|
|
</Form.Item>
|
|
<Form.Item name="confirmPwd" label="确认新密码" rules={[
|
|
{ required: true, message: '请再次输入新密码' },
|
|
({ getFieldValue }: any) => ({
|
|
validator(_: any, value: string) {
|
|
if (!value || getFieldValue('newPwd') === value) return Promise.resolve();
|
|
return Promise.reject(new Error('两次密码不一致'));
|
|
},
|
|
}),
|
|
]}>
|
|
<Input.Password placeholder="请再次输入新密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Recharge Modal */}
|
|
<Modal title={<Space><GiftOutlined />积分充值</Space>} open={rechargeModalOpen}
|
|
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
|
footer={null} width={680}>
|
|
<div style={{ marginTop: 16 }}>
|
|
<Space style={{ marginBottom: 16 }}>
|
|
<WalletOutlined style={{ color: '#6366f1' }} />
|
|
<Typography.Text style={{ color: '#64748b' }}>当前积分余额:</Typography.Text>
|
|
<Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text>
|
|
</Space>
|
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
|
{rechargeOptions.map((opt, idx) => {
|
|
const g = GRADIENTS[idx % GRADIENTS.length];
|
|
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
|
|
return (
|
|
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
|
|
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
|
|
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
|
|
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
|
|
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
|
|
}}>
|
|
{opt.description && (
|
|
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
|
|
)}
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<div style={{
|
|
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
|
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
|
|
}}>{g.icon}</div>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
|
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
|
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{totalCredits.toLocaleString()} 积分</Typography.Text>
|
|
</div>
|
|
<div style={{
|
|
fontSize: 22, fontWeight: 800, marginTop: 4,
|
|
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
|
}}>¥{opt.price}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Payment method selection */}
|
|
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
|
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
|
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
|
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
|
</Typography.Text>
|
|
</div>
|
|
) : (
|
|
<div style={{ marginTop: 20, marginBottom: 8 }}>
|
|
<Typography.Text style={{ color: '#64748b', fontSize: 13, marginBottom: 8, display: 'block' }}>选择支付方式</Typography.Text>
|
|
<Radio.Group value={paymentMethod} onChange={(e) => setPaymentMethod(e.target.value)}
|
|
style={{ display: 'flex', gap: 12 }}>
|
|
{enabledMethods.alipay && (
|
|
<Radio.Button value="alipay" style={{
|
|
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
|
borderColor: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
|
color: paymentMethod === 'alipay' ? '#1677ff' : undefined,
|
|
}}>
|
|
<AlipayCircleOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
|
支付宝
|
|
</Radio.Button>
|
|
)}
|
|
{enabledMethods.wechat && (
|
|
<Radio.Button value="wechat" style={{
|
|
flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px',
|
|
borderColor: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
|
color: paymentMethod === 'wechat' ? '#07c160' : undefined,
|
|
}}>
|
|
<WechatOutlined style={{ fontSize: 16, marginRight: 6 }} />
|
|
微信支付
|
|
</Radio.Button>
|
|
)}
|
|
</Radio.Group>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
|
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
|
<Button type="primary" size="large" disabled={!selectedPlan || (!enabledMethods.alipay && !enabledMethods.wechat)} loading={paying}
|
|
onClick={async () => {
|
|
const plan = rechargeOptions.find((opt: any) => opt.id === selectedPlan);
|
|
if (!plan) return;
|
|
const totalCredits = (plan.credits || 0) + (plan.bonus_credits || plan.bonusCredits || 0);
|
|
try {
|
|
setPaying(true);
|
|
const order = await createRechargeOrder(plan.id, paymentMethod);
|
|
if (order.paymentMethod === 'alipay' && order.qrUrl) {
|
|
// Alipay: show the real QR code URL from the backend
|
|
const paymentInfo = {
|
|
price: plan.price,
|
|
credits: totalCredits,
|
|
qrCode: order.qrUrl,
|
|
method: 'alipay',
|
|
};
|
|
setCurrentPaymentInfo(paymentInfo);
|
|
setRechargeModalOpen(false);
|
|
setQrCodeModalOpen(true);
|
|
currentOrderNoRef.current = order.orderNo;
|
|
|
|
// 保存到 localStorage
|
|
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
|
orderNo: order.orderNo,
|
|
price: plan.price,
|
|
credits: totalCredits,
|
|
qrCode: order.qrUrl,
|
|
method: 'alipay',
|
|
createdAt: order.createdAt || new Date().toISOString(),
|
|
timeoutSeconds: 180,
|
|
}));
|
|
|
|
// Start polling for payment status
|
|
startPolling(order.orderNo);
|
|
} else {
|
|
// WeChat or mock mode (mock auto-completes, no QR needed)
|
|
message.success('充值成功!积分已到账');
|
|
useAuthStore.getState().refreshUser();
|
|
setRechargeModalOpen(false);
|
|
setSelectedPlan(null);
|
|
}
|
|
} catch (err: any) {
|
|
message.error(err?.message || '创建订单失败,请重试');
|
|
} finally {
|
|
setPaying(false);
|
|
}
|
|
}}
|
|
style={{
|
|
borderRadius: 10, fontWeight: 600,
|
|
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
|
|
border: 'none', boxShadow: selectedPlan ? '0 8px 24px rgba(99,102,241,0.3)' : 'none',
|
|
}}>
|
|
确认充值
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* QR Code Payment Modal */}
|
|
<Modal
|
|
open={qrCodeModalOpen}
|
|
onCancel={async () => {
|
|
stopPolling();
|
|
// Mark order as cancelled if it's still pending
|
|
if (currentOrderNoRef.current) {
|
|
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
|
|
currentOrderNoRef.current = null;
|
|
}
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
setQrCodeModalOpen(false);
|
|
setCurrentPaymentInfo(null);
|
|
}}
|
|
footer={null}
|
|
width={400}
|
|
closable={false}
|
|
styles={{
|
|
body: { padding: 0, borderRadius: 16, overflow: 'hidden' },
|
|
}}
|
|
>
|
|
<div style={{ padding: '24px' }}>
|
|
{/* Header */}
|
|
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
|
<div style={{
|
|
width: 48, height: 48,
|
|
background: currentPaymentInfo?.method === 'alipay'
|
|
? 'linear-gradient(135deg, #1677ff, #0958d9)'
|
|
: 'linear-gradient(135deg, #07c160, #06ae56)',
|
|
borderRadius: 16,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
margin: '0 auto 12px',
|
|
}}>
|
|
{currentPaymentInfo?.method === 'alipay'
|
|
? <AlipayCircleOutlined style={{ fontSize: 24, color: '#fff' }} />
|
|
: <WechatOutlined style={{ fontSize: 24, color: '#fff' }} />}
|
|
</div>
|
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
{currentPaymentInfo?.method === 'alipay' ? '支付宝支付' : '微信支付'}
|
|
</Typography.Title>
|
|
<Typography.Text style={{ color: '#94a3b8', fontSize: 13 }}>
|
|
{currentPaymentInfo?.method === 'alipay'
|
|
? '请使用支付宝扫描二维码完成支付'
|
|
: '请使用微信扫描二维码完成支付'}
|
|
</Typography.Text>
|
|
</div>
|
|
|
|
{/* QR Code */}
|
|
<div style={{
|
|
background: '#fff',
|
|
borderRadius: 16,
|
|
padding: 20,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
boxShadow: '0 4px 20px rgba(0,0,0,0.08)',
|
|
}}>
|
|
<div style={{
|
|
width: 180,
|
|
height: 180,
|
|
borderRadius: 12,
|
|
overflow: 'hidden',
|
|
background: '#fff',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}>
|
|
{currentPaymentInfo && (
|
|
<QRCodeSVG
|
|
value={currentPaymentInfo.qrCode}
|
|
size={160}
|
|
level="M"
|
|
includeMargin={false}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
|
<div style={{
|
|
fontSize: 28,
|
|
fontWeight: 700,
|
|
color: '#1a1a2e',
|
|
}}>
|
|
¥{currentPaymentInfo?.price || 0}
|
|
</div>
|
|
<div style={{
|
|
fontSize: 13,
|
|
color: '#64748b',
|
|
marginTop: 4,
|
|
}}>
|
|
购买 {currentPaymentInfo?.credits || 0} 积分
|
|
</div>
|
|
{/* 倒计时显示 */}
|
|
<div style={{
|
|
marginTop: 12,
|
|
padding: '8px 16px',
|
|
background: countdown <= 30 ? '#fef2f2' : '#f0f9ff',
|
|
borderRadius: 8,
|
|
border: countdown <= 30 ? '1px solid #fecaca' : '1px solid #bae6fd',
|
|
display: 'inline-block',
|
|
}}>
|
|
<span style={{
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
color: countdown <= 30 ? '#dc2626' : '#0284c7',
|
|
}}>
|
|
订单将在 <span style={{ fontSize: 16, fontWeight: 800 }}>{countdown}</span> 秒后关闭
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tips */}
|
|
<div style={{ marginTop: 20, padding: 16, background: '#fef3c7', borderRadius: 12 }}>
|
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
|
<div style={{ fontSize: 16, marginTop: -2 }}>💡</div>
|
|
<div style={{ fontSize: 13, color: '#92400e' }}>
|
|
<div style={{ fontWeight: 500, marginBottom: 4 }}>支付提示</div>
|
|
<ul style={{ margin: 0, paddingLeft: 16 }}>
|
|
<li style={{ marginBottom: 2 }}>请在支付后等待页面自动跳转</li>
|
|
<li>如支付成功但未到账,请联系客服</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer Buttons */}
|
|
<div style={{ marginTop: 20 }}>
|
|
<Button
|
|
size="large"
|
|
block
|
|
onClick={async () => {
|
|
stopPolling();
|
|
if (currentOrderNoRef.current) {
|
|
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
|
|
currentOrderNoRef.current = null;
|
|
}
|
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
|
setQrCodeModalOpen(false);
|
|
setCurrentPaymentInfo(null);
|
|
setSelectedPlan(null);
|
|
}}
|
|
style={{ borderRadius: 10 }}
|
|
>
|
|
取消支付
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Message Center Modal */}
|
|
<Modal
|
|
open={msgModalOpen}
|
|
onCancel={() => { setMsgModalOpen(false); }}
|
|
footer={null} width={520}
|
|
closable={false}
|
|
styles={{
|
|
body: { padding: 0, borderRadius: 16, overflow: 'hidden' },
|
|
}}
|
|
>
|
|
{/* Header */}
|
|
<div style={{
|
|
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
|
padding: '20px 24px',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: 10,
|
|
background: 'rgba(255,255,255,0.2)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
backdropFilter: 'blur(4px)',
|
|
}}>
|
|
<BellOutlined style={{ fontSize: 18, color: '#fff' }} />
|
|
</div>
|
|
<span style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>消息中心</span>
|
|
{unreadCount > 0 && (
|
|
<span style={{
|
|
background: 'rgba(255,255,255,0.25)', color: '#fff',
|
|
fontSize: 11, fontWeight: 600, borderRadius: 10,
|
|
padding: '2px 10px', backdropFilter: 'blur(4px)',
|
|
}}>{unreadCount} 条未读</span>
|
|
)}
|
|
</div>
|
|
<Button type="text" icon={<CloseOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />}
|
|
onClick={() => setMsgModalOpen(false)}
|
|
style={{ color: '#fff' }} />
|
|
</div>
|
|
|
|
{/* List */}
|
|
<div style={{ maxHeight: 460, overflow: 'auto', padding: '12px 16px 16px' }}>
|
|
{allNotifications.length === 0 ? (
|
|
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
|
<BellOutlined style={{ fontSize: 40, color: '#cbd5e1', marginBottom: 12 }} />
|
|
<div style={{ color: '#94a3b8', fontSize: 14 }}>暂无消息</div>
|
|
</div>
|
|
) : (
|
|
allNotifications.map((n: any) => {
|
|
const isRead = n.isRead ?? n.is_read;
|
|
const typeConfig: Record<string, { color: string; bg: string; label: string }> = {
|
|
system: { color: '#6366f1', bg: 'rgba(99,102,241,0.1)', label: '系统' },
|
|
credit: { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', label: '积分' },
|
|
promo: { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)', label: '活动' },
|
|
};
|
|
const tc = typeConfig[n.type] || typeConfig.system;
|
|
return (
|
|
<div key={n.id} style={{
|
|
padding: '16px', borderRadius: 12, marginBottom: 8,
|
|
background: isRead ? '#fff' : 'linear-gradient(135deg, rgba(99,102,241,0.03), rgba(139,92,246,0.03))',
|
|
border: isRead ? '1px solid #f0f0f5' : '1px solid rgba(99,102,241,0.18)',
|
|
cursor: isRead ? 'default' : 'pointer',
|
|
transition: 'all 0.2s',
|
|
position: 'relative',
|
|
}} onClick={async () => {
|
|
if (!isRead) {
|
|
await markNotificationRead(n.id);
|
|
loadNotifications();
|
|
}
|
|
}}
|
|
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.04)'; }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}
|
|
>
|
|
<div style={{ display: 'flex', gap: 12 }}>
|
|
{/* Type icon */}
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
|
background: tc.bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<BellOutlined style={{ color: tc.color, fontSize: 15 }} />
|
|
</div>
|
|
{/* Content */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
{!isRead && <span style={{
|
|
width: 7, height: 7, borderRadius: '50%',
|
|
background: '#6366f1', flexShrink: 0,
|
|
boxShadow: '0 0 6px rgba(99,102,241,0.4)',
|
|
}} />}
|
|
<span style={{ fontWeight: 600, fontSize: 14, color: '#1e293b' }}>{n.title}</span>
|
|
</div>
|
|
<span style={{ fontSize: 11, color: '#94a3b8', flexShrink: 0 }}>
|
|
{n.createdAt ? n.createdAt.replace('T', ' ').slice(0, 16) : ''}
|
|
</span>
|
|
</div>
|
|
<div style={{
|
|
fontSize: 13, color: '#64748b', lineHeight: 1.7,
|
|
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
|
}}>{n.content}</div>
|
|
<Tag color={tc.color} style={{ marginTop: 8, borderRadius: 6, border: 'none', fontSize: 11, padding: '1px 8px' }}>
|
|
{tc.label}
|
|
</Tag>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
|
|
<NotificationPopup />
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default AppLayout;
|