Files
video-gen/video-gen-app/src/components/Layout/AppLayout.tsx
T
2026-06-17 16:33:52 +08:00

962 lines
39 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,
SettingOutlined,
AppstoreOutlined,
FileTextOutlined,
StarOutlined,
HeartOutlined,
CameraOutlined,
CalculatorOutlined,
DollarOutlined,
FireOutlined,
CloudOutlined,
SmileOutlined,
TrophyOutlined,
RocketOutlined,
BulbOutlined,
PictureOutlined,
VideoCameraOutlined,
AudioOutlined,
MailOutlined,
PhoneOutlined,
GlobalOutlined,
ShoppingCartOutlined,
TeamOutlined,
BarChartOutlined,
PieChartOutlined,
LineChartOutlined,
SecurityScanOutlined,
ApiOutlined,
DatabaseOutlined,
CloudServerOutlined,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount } 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: <SettingOutlined />,
BellOutlined: <BellOutlined />,
UserOutlined: <UserOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
FileTextOutlined: <FileTextOutlined />,
StarOutlined: <StarOutlined />,
HeartOutlined: <HeartOutlined />,
CameraOutlined: <CameraOutlined />,
RobotOutlined: <PlayCircleOutlined />,
CalculatorOutlined: <CalculatorOutlined />,
DollarOutlined: <DollarOutlined />,
GiftOutlined: <GiftOutlined />,
ThunderboltOutlined: <ThunderboltOutlined />,
FireOutlined: <FireOutlined />,
CloudOutlined: <CloudOutlined />,
SmileOutlined: <SmileOutlined />,
TrophyOutlined: <TrophyOutlined />,
RocketOutlined: <RocketOutlined />,
BulbOutlined: <BulbOutlined />,
PictureOutlined: <PictureOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
AudioOutlined: <AudioOutlined />,
MailOutlined: <MailOutlined />,
PhoneOutlined: <PhoneOutlined />,
GlobalOutlined: <GlobalOutlined />,
ShoppingCartOutlined: <ShoppingCartOutlined />,
TeamOutlined: <TeamOutlined />,
BarChartOutlined: <BarChartOutlined />,
PieChartOutlined: <PieChartOutlined />,
LineChartOutlined: <LineChartOutlined />,
SecurityScanOutlined: <SecurityScanOutlined />,
ApiOutlined: <ApiOutlined />,
DatabaseOutlined: <DatabaseOutlined />,
CloudServerOutlined: <CloudServerOutlined />,
};
const SIDEBAR_W = 240;
const GRADIENTS = [
{ gradient: 'linear-gradient(135deg, #c9a96e, #a67c52)', shadow: 'rgba(201,169,110,0.25)', icon: <StarFilled /> },
{ gradient: 'linear-gradient(135deg, #4a5568, #2d3748)', shadow: 'rgba(74,85,104,0.25)', icon: <FireFilled /> },
{ gradient: 'linear-gradient(135deg, #718096, #4a5568)', shadow: 'rgba(113,128,150,0.25)', icon: <CrownFilled /> },
{ gradient: 'linear-gradient(135deg, #5a67d8, #434190)', shadow: 'rgba(90,103,216,0.25)', 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 [selectedPlan, setSelectedPlan] = useState<number | null>(null);
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [unreadCount, setUnreadCount] = useState(0);
const [siteName, setSiteName] = useState(() => {
const cached = localStorage.getItem('siteInfo');
const name = cached ? JSON.parse(cached).siteName || '' : '';
if (name) document.title = name;
return name;
});
const [siteLogo, setSiteLogo] = useState(() => {
const cached = localStorage.getItem('siteInfo');
const logo = cached ? JSON.parse(cached).siteLogo || '' : '';
if (logo) {
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
if (!faviconLink) {
faviconLink = document.createElement('link');
faviconLink.rel = 'icon';
document.head.appendChild(faviconLink);
}
faviconLink.href = logo;
faviconLink.type = 'image/png';
}
return logo;
});
const [siteInfoLoading, setSiteInfoLoading] = useState(!localStorage.getItem('siteInfo'));
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(() => {
getSiteInfo().then(info => {
const name = info.siteName || '民众智创';
const logo = info.siteLogo || '';
if (name !== siteName) {
setSiteName(name);
document.title = name;
}
if (logo && logo !== siteLogo) {
setSiteLogo(logo);
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
if (!faviconLink) {
faviconLink = document.createElement('link');
faviconLink.rel = 'icon';
document.head.appendChild(faviconLink);
}
faviconLink.href = logo;
faviconLink.type = 'image/png';
}
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
}).catch(() => { });
}, []);
const loadUnreadCount = () => {
getUnreadCount().then(count => {
setUnreadCount(count);
}).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(() => { });
loadUnreadCount();
}, [user]);
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
const sidebarW = SIDEBAR_W;
const userMenuItems = [
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
{ key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
{ type: 'divider' as const },
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
// { key: 'recharge', icon: <PlusCircleOutlined />, label: '充值积分' },
{ 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); }
else if (key === 'messages') { navigate('/messages'); }
else if (key === 'recharge') { setRechargeModalOpen(true); }
else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
};
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: 16, top: 16, bottom: 16, zIndex: 100,
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
borderRadius: '20px',
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
display: 'flex', flexDirection: 'column',
transition: 'width 0.25s ease, left 0.25s ease',
overflow: 'hidden',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}>
{/* Logo */}
<div style={{
height: 80, display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
padding: '0 20px',
flexShrink: 0,
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 42, height: 42, borderRadius: 14, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
overflow: 'hidden',
}}>
{siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
) : (
<ThunderboltOutlined style={{ fontSize: 20, color: '#ffffff' }} />
)}
</div>
<span style={{
color: '#1e293b', fontSize: 17, fontWeight: 700, letterSpacing: -0.02,
whiteSpace: 'nowrap',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}>
{siteName}
</span>
</div>
</div>
{/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{/* {!collapsed && (
<div style={{ color: 'rgba(0,0,0,0.3)', 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 />;
return (
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
gap: 12,
padding: depth > 0 ? '8px 14px 8px 36px' : '10px 16px',
borderRadius: 12, margin: '2px 6px', cursor: 'pointer',
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
color: isActive ? '#4f46e5' : '#475569',
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
transition: 'all 0.2s ease',
}}>
<span style={{
fontSize: depth > 0 ? 14 : 16,
flexShrink: 0,
color: isActive ? '#6366f1' : '#64748b',
}}>{menuIcon}</span>
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
</div>
);
};
// 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));
items.push(
<div key={g.id}>
<div style={{
color: '#94a3b8', fontSize: 12, fontWeight: 600,
padding: '12px 16px 6px', letterSpacing: 0.5, textTransform: 'uppercase',
}}>
{g.label}
</div>
{children.map(c => renderMenuItem(c, 1))}
</div>
);
});
// Render top-level pages
topLevel.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(m => {
items.push(renderMenuItem(m));
});
return items;
})()}
</div>
{/* Recharge button - opens modal */}
<div onClick={() => setRechargeModalOpen(true)} style={{
margin: '8px 12px',
padding: '12px 20px',
borderRadius: 14, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: 8,
color: '#ffffff', fontSize: 14, fontWeight: 600,
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.4)',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)';
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(99, 102, 241, 0.5)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)';
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(99, 102, 241, 0.4)';
}}
>
<PlusOutlined style={{ fontSize: 16 }} />
<span>充值积分</span>
</div>
{/* User block at bottom-left */}
<div style={{ padding: '16px 16px', flexShrink: 0 }}>
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
<div style={{
display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
gap: 12,
padding: '10px 14px',
borderRadius: 14, cursor: 'pointer',
transition: 'all 0.25s ease',
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.08)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(0, 0, 0, 0.04)';
}}
>
<Avatar size={36} icon={<UserOutlined />}
style={{
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
flexShrink: 0,
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)',
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: '#1e293b', fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
{user?.username}
</div>
<div style={{
color: '#6366f1',
fontSize: 12,
fontWeight: 500,
letterSpacing: 0,
background: 'rgba(99, 102, 241, 0.08)',
padding: '2px 8px',
borderRadius: 6,
display: 'inline-block',
}}>积分: {user?.credits || 0}</div>
</div>
</div>
</Dropdown>
</div>
</div>
{/* Main Content */}
<div className="desktop-content" style={{
marginLeft: sidebarW + 48,
marginRight: 32,
marginTop: 16,
marginBottom: 16,
flex: 1,
// height: '100%',
minHeight: 'calc(100vh - 32px)',
background: 'transparent',
padding: 0,
transition: 'margin-left 0.25s ease',
}}>
<div style={{
boxSizing: 'border-box',
height: '100%',
background: '#ffffffff',
borderRadius: '20px',
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
minHeight: '100%',
padding: '24px 32px 32px',
border: '1px solid rgba(0, 0, 0, 0.06)',
}}>
<Outlet />
</div>
</div>
{/* Mobile Bottom Nav */}
<div className="mobile-bottom-nav">
{menuItems.filter((item) => (item.menu_type ?? item.menuType) !== 'group').map((item) => {
if (!item.path) return null;
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', letterSpacing: 0 }}>当前积分余额</Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 20, fontWeight: 600 }}>{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);
// 检查是否有二维码信息,支持支付宝和微信支付
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
// Alipay or WeChat Pay: show the QR code
const paymentInfo = {
price: plan.price,
credits: totalCredits,
qrCode: qrCode,
method: order.paymentMethod,
};
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: qrCode,
method: order.paymentMethod,
createdAt: order.createdAt || new Date().toISOString(),
timeoutSeconds: 180,
}));
// Start polling for payment status
startPolling(order.orderNo);
} else {
// Mock mode (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>
<NotificationPopup />
</Layout>
);
};
export default AppLayout;