1597 lines
60 KiB
TypeScript
1597 lines
60 KiB
TypeScript
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio, Drawer } 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,
|
||
MessageOutlined,
|
||
DownOutlined,
|
||
InfoOutlined,
|
||
MenuOutlined,
|
||
ArrowLeftOutlined,
|
||
} 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, createContactRequest, getUser } from '../../api';
|
||
import NotificationPopup from '../NotificationPopup';
|
||
import './AppLayout.css';
|
||
|
||
// ── ResourceCapacity 类型(与 src/types/index.ts 保持一致) ──
|
||
type ResourceCapacityData = {
|
||
enabled: boolean;
|
||
usedBytes: number;
|
||
totalBytes: number;
|
||
availableBytes: number;
|
||
usagePercent: number;
|
||
exceeded: boolean;
|
||
limitValue: string;
|
||
limitUnit: string;
|
||
};
|
||
|
||
/**
|
||
* 资源存储展示卡片
|
||
* - enabled === true:显示「已用 / 总额」+ 进度条(超额时变红并提示)
|
||
* - enabled === false:按 usedBytes 大小自动选 KB / MB / GB / TB 单位显示「当前使用」
|
||
*/
|
||
const StorageCard: React.FC<{ data: ResourceCapacityData | null }> = ({ data }) => {
|
||
if (!data) return null;
|
||
|
||
// 单位:后端返回的 limitUnit(KB / MB / GB)
|
||
const unit = (data.limitUnit || 'GB').toUpperCase();
|
||
const divisor = unit === 'GB' ? 1024 ** 3 : unit === 'MB' ? 1024 ** 2 : 1024;
|
||
|
||
// 百分比(可能 > 100)
|
||
const rawPercent = Number(data.usagePercent) || 0;
|
||
// 进度条宽度最多 100%
|
||
const barPercent = Math.min(100, Math.max(0, rawPercent));
|
||
|
||
// 总额:优先用后端 limitValue,兜底 totalBytes / divisor
|
||
const total = data.limitValue
|
||
? parseFloat(data.limitValue)
|
||
: data.totalBytes / divisor;
|
||
|
||
// 已用 = 总额 × 百分比 / 100(保证数字与 percent 自洽)
|
||
const used = (total * rawPercent) / 100;
|
||
// 剩余 = 总额 - 已用
|
||
const available = total - used;
|
||
|
||
// 超额判断
|
||
const isOver = data.exceeded || rawPercent >= 100;
|
||
|
||
// enabled === false 时按 usedBytes 自动选单位
|
||
const KB = 1024;
|
||
const MB = 1024 ** 2;
|
||
const GB = 1024 ** 3;
|
||
const TB = 1024 ** 4;
|
||
const formatUsedAuto = (bytes: number) => {
|
||
const b = Number(bytes) || 0;
|
||
if (b >= TB) return { val: (b / TB).toFixed(2), unit: 'TB' };
|
||
if (b >= GB) return { val: (b / GB).toFixed(2), unit: 'GB' };
|
||
if (b >= MB) return { val: (b / MB).toFixed(2), unit: 'MB' };
|
||
return { val: (b / KB).toFixed(2), unit: 'KB' };
|
||
};
|
||
const usedAuto = formatUsedAuto(data.usedBytes);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
// marginTop: 12,
|
||
padding: '0 12px',
|
||
paddingTop: 6,
|
||
borderRadius: 10,
|
||
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
|
||
// border: `1px solid ${isOver ? 'rgba(239, 68, 68, 0.25)' : 'rgba(99, 102, 241, 0.15)'}`,
|
||
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04)',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 6,
|
||
fontSize: 12,
|
||
color: '#475569',
|
||
}}
|
||
>
|
||
{/* <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ef4444' : '#6366f1' }} />
|
||
{rawPercent.toFixed(1)}%
|
||
</span> */}
|
||
{data.enabled ? (
|
||
<></>
|
||
) : (
|
||
|
||
<span style={{ fontWeight: 500, color: '#1e293b' }}>
|
||
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ef4444' : '#6366f1', marginRight: 4 }} />
|
||
|
||
当前使用 {usedAuto.val} {usedAuto.unit}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{data.enabled && (
|
||
<>
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
height: 20,
|
||
background: '#e2e8f0',
|
||
borderRadius: 2,
|
||
overflow: 'hidden',
|
||
position: 'relative',
|
||
|
||
}}
|
||
>
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: 0,
|
||
left: 0,
|
||
height: '100%',
|
||
background: 'transparent',
|
||
width: '100%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
marginBottom: 6,
|
||
fontSize: 12,
|
||
color: isOver ? '#ffffffff' : '#000000ff',
|
||
padding: '0 8px',
|
||
|
||
}}>
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#ffffffff' :'#000000ff' }} />
|
||
{rawPercent.toFixed(1)}%
|
||
</span>
|
||
{data.enabled ? (
|
||
<span style={{ fontWeight: 500, color: isOver ? '#ffffffff' : '#000000ff' }}>
|
||
{used.toFixed(2)} / {total.toFixed(2)} {unit}
|
||
</span>
|
||
) : (
|
||
<span style={{ fontWeight: 500, color: '#1e293b' }}>
|
||
当前使用 {usedAuto.val} {usedAuto.unit}
|
||
</span>
|
||
)}
|
||
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
width: `${barPercent}%`,
|
||
height: '100%',
|
||
|
||
background: rawPercent < 50
|
||
? 'linear-gradient(90deg, #279951, #b7fad0)'
|
||
: rawPercent < 85
|
||
? 'linear-gradient(90deg, #ffd759, #fff6d4)'
|
||
: 'linear-gradient(90deg, #ef4444, #dc2626)',
|
||
borderRadius: 2,
|
||
transition: 'width 0.4s ease',
|
||
}}
|
||
/>
|
||
|
||
</div>
|
||
<div
|
||
style={{
|
||
marginTop: 4,
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
fontSize: 11,
|
||
color: isOver ? '#ef4444' : '#94a3b8',
|
||
}}
|
||
>
|
||
{/* <span>
|
||
{isOver
|
||
? `存储超额 · 超用 ${Math.abs(available).toFixed(2)} ${unit}`
|
||
: `剩余 ${available.toFixed(2)} ${unit}`}
|
||
</span> */}
|
||
{/* <span>{rawPercent.toFixed(1)}%</span> */}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
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 [contactModalOpen, setContactModalOpen] = useState(false);
|
||
const [pwdForm] = Form.useForm();
|
||
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
|
||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
||
const [unreadCount, setUnreadCount] = useState(0);
|
||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||
const [contactHovered, setContactHovered] = useState(false);
|
||
const [contactForm] = Form.useForm();
|
||
const [submittingContact, setSubmittingContact] = useState(false);
|
||
const [contactPosition, setContactPosition] = useState<{ x: number; y: number }>(() => ({
|
||
x: 24,
|
||
y: window.innerHeight * 0.75
|
||
}));
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
const hasMovedRef = useRef(false);
|
||
const dragStartRef = useRef({ x: 0, y: 0 });
|
||
const [mobileExpandedMenus, setMobileExpandedMenus] = useState<Record<string, boolean>>({});
|
||
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);
|
||
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 });
|
||
|
||
// 资源存储容量(从 getUser().resource_capacity 获取)
|
||
const [resourceCapacity, setResourceCapacity] = useState<{
|
||
enabled: boolean;
|
||
usedBytes: number;
|
||
totalBytes: number;
|
||
availableBytes: number;
|
||
usagePercent: number;
|
||
exceeded: boolean;
|
||
limitValue: string;
|
||
limitUnit: string;
|
||
} | null>(null);
|
||
const [isMobile, setIsMobile] = useState(false);
|
||
|
||
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(() => { });
|
||
|
||
getUser().then((res: any) => {
|
||
// console.log('[Storage] getUser 返回:', res);
|
||
|
||
const rc = res?.resourceCapacity;
|
||
if (rc) {
|
||
setResourceCapacity({
|
||
enabled: !!rc.enabled,
|
||
usedBytes: Number(rc.usedBytes) || 0,
|
||
totalBytes: Number(rc.totalBytes) || 0,
|
||
availableBytes: Number(rc.availableBytes) || 0,
|
||
usagePercent: Number(rc.usagePercent) || 0,
|
||
exceeded: !!rc.exceeded,
|
||
limitValue: rc.limitValue ?? '',
|
||
limitUnit: rc.limitUnit || 'GB',
|
||
});
|
||
}
|
||
// 没数据时不显示
|
||
}).catch((err: any) => {
|
||
// console.error('[Storage] getUser 失败:', err);
|
||
// 接口失败也不显示
|
||
});
|
||
|
||
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const checkMobile = () => setIsMobile(window.innerWidth <= 767);
|
||
checkMobile();
|
||
window.addEventListener('resize', checkMobile);
|
||
return () => window.removeEventListener('resize', checkMobile);
|
||
}, []);
|
||
|
||
const handleContactMouseDown = (e: React.MouseEvent) => {
|
||
if (e.button === 0) {
|
||
setIsDragging(true);
|
||
hasMovedRef.current = false;
|
||
dragStartRef.current = {
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
};
|
||
}
|
||
};
|
||
|
||
const handleContactMouseMove = (e: MouseEvent) => {
|
||
if (!isDragging) return;
|
||
|
||
const deltaX = Math.abs(e.clientX - dragStartRef.current.x);
|
||
const deltaY = Math.abs(e.clientY - dragStartRef.current.y);
|
||
|
||
if (deltaX > 5 || deltaY > 5) {
|
||
hasMovedRef.current = true;
|
||
}
|
||
|
||
const newY = Math.max(60, Math.min(window.innerHeight - 60, e.clientY - (dragStartRef.current.y - contactPosition.y)));
|
||
|
||
setContactPosition(prev => ({ x: prev.x, y: newY }));
|
||
};
|
||
|
||
const handleContactMouseUp = () => {
|
||
const moved = hasMovedRef.current;
|
||
setIsDragging(false);
|
||
hasMovedRef.current = false;
|
||
|
||
if (!moved) {
|
||
setContactModalOpen(true);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (isDragging) {
|
||
document.addEventListener('mousemove', handleContactMouseMove);
|
||
document.addEventListener('mouseup', handleContactMouseUp);
|
||
return () => {
|
||
document.removeEventListener('mousemove', handleContactMouseMove);
|
||
document.removeEventListener('mouseup', handleContactMouseUp);
|
||
};
|
||
}
|
||
}, [isDragging]);
|
||
|
||
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 => {
|
||
const items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
|
||
setMenuItems(items);
|
||
}).catch(() => { });
|
||
getRechargePackages().then(data => {
|
||
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
|
||
}).catch(() => { });
|
||
getPaymentMethods().then(data => {
|
||
setEnabledMethods(data);
|
||
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 childMap: Record<string, MenuConfig[]> = {};
|
||
menuItems.forEach(m => {
|
||
const pid = m.parent_id ?? m.parentId;
|
||
if (pid) {
|
||
if (!childMap[pid]) childMap[pid] = [];
|
||
childMap[pid].push(m);
|
||
}
|
||
});
|
||
|
||
const topLevelItems = menuItems.filter(m => !(m.parent_id ?? m.parentId));
|
||
topLevelItems.sort((a, b) => {
|
||
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
|
||
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
|
||
return orderA - orderB;
|
||
});
|
||
|
||
Object.keys(childMap).forEach(key => {
|
||
childMap[key].sort((a, b) => {
|
||
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
|
||
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
|
||
return orderA - orderB;
|
||
});
|
||
});
|
||
|
||
const handleMobileMenuClick = (item: MenuConfig) => {
|
||
const menuType = item.menu_type ?? item.menuType;
|
||
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
|
||
|
||
if (menuType === 'group' || hasChildren) {
|
||
setMobileExpandedMenus(prev => ({
|
||
...prev,
|
||
[item.id]: !prev[item.id]
|
||
}));
|
||
} else if (item.path) {
|
||
navigate(item.path);
|
||
setMobileMenuOpen(false);
|
||
}
|
||
};
|
||
|
||
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})` : ''}` },
|
||
{ 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 { }
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
logout();
|
||
navigate('/login');
|
||
};
|
||
|
||
const handleMobileRecharge = () => {
|
||
setRechargeModalOpen(true);
|
||
};
|
||
|
||
const handleContactSubmit = async () => {
|
||
if (!user) {
|
||
message.warning('请先登录');
|
||
return;
|
||
}
|
||
try {
|
||
const values = await contactForm.validateFields();
|
||
setSubmittingContact(true);
|
||
await createContactRequest({
|
||
phone: values.phone,
|
||
company_name: values.companyName,
|
||
industry: values.industry,
|
||
name: values.name,
|
||
message: values.message,
|
||
});
|
||
message.success('提交成功,我们会尽快与您联系');
|
||
setContactModalOpen(false);
|
||
contactForm.resetFields();
|
||
} catch (err: any) {
|
||
message.error(err?.message || '提交失败');
|
||
} finally {
|
||
setSubmittingContact(false);
|
||
}
|
||
};
|
||
|
||
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);
|
||
|
||
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 {
|
||
}
|
||
}, 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]);
|
||
|
||
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
||
const isActive = item.path === selectedKey;
|
||
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
||
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
|
||
const menuType = item.menu_type ?? item.menuType;
|
||
const isGroup = menuType === 'group';
|
||
|
||
return (
|
||
<div key={item.id}>
|
||
<div
|
||
onClick={() => {
|
||
if (!(isGroup || hasChildren) && item.path) {
|
||
navigate(item.path);
|
||
}
|
||
}}
|
||
style={{
|
||
display: 'flex', alignItems: 'center',
|
||
justifyContent: 'flex-start',
|
||
gap: 10,
|
||
padding: depth > 0 ? '6px 12px 6px 32px' : '6px 14px',
|
||
borderRadius: 12, margin: '1px 4px',
|
||
cursor: isGroup || hasChildren ? 'default' : 'pointer',
|
||
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
|
||
color: isActive ? '#4f46e5' : (isGroup ? '#94a3b8' : '#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',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isGroup && !isActive && !(isGroup || hasChildren)) {
|
||
e.currentTarget.style.background = 'rgba(99, 102, 241, 0.05)';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isActive) {
|
||
e.currentTarget.style.background = 'transparent';
|
||
}
|
||
}}
|
||
>
|
||
{!isGroup && (
|
||
<span style={{
|
||
fontSize: depth > 0 ? 14 : 16,
|
||
flexShrink: 0,
|
||
color: isActive ? '#6366f1' : '#64748b',
|
||
}}>{menuIcon}</span>
|
||
)}
|
||
<span style={{ whiteSpace: 'nowrap', flex: 1, textAlign: 'left', fontWeight: isGroup ? 600 : (isActive ? 600 : 400), fontSize: isGroup ? 12 : (depth > 0 ? 13 : 14), textTransform: isGroup ? 'uppercase' : 'none', letterSpacing: isGroup ? 0.5 : 0 }}>
|
||
{item.label}
|
||
</span>
|
||
</div>
|
||
|
||
{(isGroup || hasChildren) && (
|
||
<div style={{ overflow: 'hidden' }}>
|
||
{(childMap[item.id] || []).map(c => renderMenuItem(c, depth + 1))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Layout style={{ minHeight: '100vh' }}>
|
||
<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)',
|
||
}}>
|
||
<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, cursor: 'pointer' }}
|
||
onClick={() => {
|
||
navigate('/home');
|
||
}}>
|
||
<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>
|
||
|
||
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
||
{topLevelItems.map(item => renderMenuItem(item))}
|
||
</div>
|
||
|
||
<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>
|
||
{/* 资源存储容量展示(来自 getUser.resourceCapacity) */}
|
||
<StorageCard data={resourceCapacity} />
|
||
|
||
<div style={{ padding: '16px 16px', flexShrink: 0, paddingTop: 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: 16, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', letterSpacing: -0.01 }}>
|
||
{user?.username}
|
||
</div>
|
||
<div style={{
|
||
color: '#6366f1',
|
||
fontSize: 16,
|
||
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>
|
||
|
||
<div className="desktop-content" style={{
|
||
marginLeft: sidebarW + 28,
|
||
marginRight: 12,
|
||
marginTop: 16,
|
||
marginBottom: 16,
|
||
flex: 1,
|
||
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)',
|
||
}}>
|
||
{!isMobile && <Outlet />}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mobile-header">
|
||
<div className="mobile-header-content">
|
||
<div className="mobile-menu-btn" onClick={() => setMobileMenuOpen(true)}>
|
||
<MenuOutlined style={{ fontSize: 20 }} />
|
||
</div>
|
||
<div className="mobile-header-title">{siteName}</div>
|
||
<div className="mobile-header-right">
|
||
<div className="mobile-credits-badge" onClick={() => setRechargeModalOpen(true)}>
|
||
<WalletOutlined />
|
||
<span>{user?.credits || 0}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mobile-content">
|
||
{isMobile && <Outlet />}
|
||
</div>
|
||
|
||
<Drawer
|
||
title={
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||
<div style={{
|
||
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.35)',
|
||
}}>
|
||
{siteLogo ? (
|
||
<img src={siteLogo} alt="logo" style={{ width: 24, height: 24, objectFit: 'contain' }} />
|
||
) : (
|
||
<ThunderboltOutlined style={{ fontSize: 18, color: '#ffffff' }} />
|
||
)}
|
||
</div>
|
||
<span style={{ fontWeight: 700, fontSize: 16, color: '#1e293b' }}>{siteName}</span>
|
||
</div>
|
||
}
|
||
placement="left"
|
||
onClose={() => setMobileMenuOpen(false)}
|
||
open={mobileMenuOpen}
|
||
width={280}
|
||
closable={true}
|
||
className="mobile-menu-drawer"
|
||
styles={{
|
||
header: { borderBottom: '1px solid #f1f5f9', padding: '16px 20px' },
|
||
body: { padding: '12px 8px', display: 'flex', flexDirection: 'column' },
|
||
}}
|
||
>
|
||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 12 }}>
|
||
<div style={{ padding: '12px 8px 16px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||
<Avatar size={44} style={{ background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)', flexShrink: 0 }}>
|
||
<UserOutlined />
|
||
</Avatar>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{ fontSize: 15, fontWeight: 600, color: '#1e293b', marginBottom: 2 }}>
|
||
{user?.username || '用户'}
|
||
</div>
|
||
<div style={{ fontSize: 13, color: '#64748b' }}>
|
||
<WalletOutlined style={{ color: '#f59e0b', marginRight: 4 }} />
|
||
积分: <span style={{ color: '#f59e0b', fontWeight: 600 }}>{user?.credits ?? 0}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 资源存储容量展示(与桌面端共用 StorageCard) */}
|
||
<StorageCard data={resourceCapacity} />
|
||
|
||
{topLevelItems.map(item => {
|
||
const menuType = item.menu_type ?? item.menuType;
|
||
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
|
||
const isActive = item.path === selectedKey;
|
||
const isExpanded = mobileExpandedMenus[item.id];
|
||
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
||
|
||
return (
|
||
<div key={item.id}>
|
||
<div
|
||
className={`mobile-menu-item ${isActive && !hasChildren ? 'mobile-menu-item-active' : ''} ${menuType === 'group' ? 'mobile-menu-group' : ''}`}
|
||
onClick={() => handleMobileMenuClick(item)}
|
||
>
|
||
{menuType !== 'group' && (
|
||
<span className="mobile-menu-icon" style={{ color: isActive ? '#6366f1' : '#64748b' }}>
|
||
{menuIcon}
|
||
</span>
|
||
)}
|
||
<span className="mobile-menu-label" style={{
|
||
paddingLeft: menuType === 'group' ? 0 : 0,
|
||
color: menuType === 'group' ? '#94a3b8' : (isActive ? '#4f46e5' : '#475569'),
|
||
fontWeight: menuType === 'group' ? 600 : (isActive ? 600 : 400),
|
||
fontSize: menuType === 'group' ? 12 : 15,
|
||
textTransform: menuType === 'group' ? 'uppercase' : 'none',
|
||
letterSpacing: menuType === 'group' ? 0.5 : 0,
|
||
}}>
|
||
{item.label}
|
||
</span>
|
||
{(menuType === 'group' || hasChildren) && (
|
||
<span style={{
|
||
fontSize: 12,
|
||
color: '#cbd5e1',
|
||
transition: 'transform 0.2s ease',
|
||
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||
}}>
|
||
<RightOutlined />
|
||
</span>
|
||
)}
|
||
</div>
|
||
{(menuType === 'group' || hasChildren) && isExpanded && (
|
||
<div className="mobile-submenu">
|
||
{(childMap[item.id] || []).map(child => {
|
||
const childActive = child.path === selectedKey;
|
||
const childIcon = iconMap[child.icon] || <HomeOutlined />;
|
||
return (
|
||
<div
|
||
key={child.id}
|
||
className={`mobile-menu-item mobile-submenu-item ${childActive ? 'mobile-menu-item-active' : ''}`}
|
||
onClick={() => handleMobileMenuClick(child)}
|
||
>
|
||
<span className="mobile-menu-icon" style={{ color: childActive ? '#6366f1' : '#94a3b8' }}>
|
||
{childIcon}
|
||
</span>
|
||
<span className="mobile-menu-label" style={{
|
||
color: childActive ? '#4f46e5' : '#64748b',
|
||
fontWeight: childActive ? 600 : 400,
|
||
fontSize: 14,
|
||
}}>
|
||
{child.label}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div style={{ padding: '8px 0', borderTop: '1px solid #f1f5f9' }}>
|
||
<div
|
||
className="mobile-menu-item"
|
||
onClick={() => {
|
||
navigate('/user-center?tab=credits');
|
||
setMobileMenuOpen(false);
|
||
}}
|
||
>
|
||
<span className="mobile-menu-icon" style={{ color: '#f59e0b' }}>
|
||
<WalletOutlined />
|
||
</span>
|
||
<span className="mobile-menu-label" style={{ color: '#475569' }}>积分明细</span>
|
||
</div>
|
||
|
||
<div
|
||
className="mobile-menu-item"
|
||
onClick={() => {
|
||
navigate('/user-center?tab=orders');
|
||
setMobileMenuOpen(false);
|
||
}}
|
||
>
|
||
<span className="mobile-menu-icon" style={{ color: '#6366f1' }}>
|
||
<FileTextOutlined />
|
||
</span>
|
||
<span className="mobile-menu-label" style={{ color: '#475569' }}>订单记录</span>
|
||
</div>
|
||
|
||
<div
|
||
className="mobile-menu-item"
|
||
onClick={() => {
|
||
navigate('/messages');
|
||
setMobileMenuOpen(false);
|
||
}}
|
||
>
|
||
<span className="mobile-menu-icon" style={{ color: '#8b5cf6' }}>
|
||
<BellOutlined />
|
||
</span>
|
||
<span className="mobile-menu-label" style={{ color: '#475569' }}>
|
||
消息中心
|
||
{unreadCount > 0 && (
|
||
<Tag color="red" style={{ marginLeft: 8, fontSize: 11 }}>{unreadCount}</Tag>
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
<div
|
||
className="mobile-menu-item"
|
||
onClick={() => {
|
||
setPwdModalOpen(true);
|
||
setMobileMenuOpen(false);
|
||
}}
|
||
>
|
||
<span className="mobile-menu-icon" style={{ color: '#0ea5e9' }}>
|
||
<LockOutlined />
|
||
</span>
|
||
<span className="mobile-menu-label" style={{ color: '#475569' }}>修改密码</span>
|
||
</div>
|
||
|
||
<div style={{ height: 8 }} />
|
||
|
||
<div
|
||
className="mobile-menu-item mobile-recharge-item"
|
||
onClick={() => {
|
||
setRechargeModalOpen(true);
|
||
setMobileMenuOpen(false);
|
||
}}
|
||
>
|
||
<span className="mobile-menu-icon" style={{ color: '#fff' }}>
|
||
<PlusOutlined />
|
||
</span>
|
||
<span className="mobile-menu-label" style={{ color: '#fff', fontWeight: 600 }}>充值积分</span>
|
||
</div>
|
||
|
||
<div
|
||
className="mobile-menu-item"
|
||
onClick={() => {
|
||
handleLogout();
|
||
setMobileMenuOpen(false);
|
||
}}
|
||
>
|
||
<span className="mobile-menu-icon" style={{ color: '#ef4444' }}>
|
||
<LogoutOutlined />
|
||
</span>
|
||
<span className="mobile-menu-label" style={{ color: '#ef4444' }}>退出登录</span>
|
||
</div>
|
||
</div>
|
||
</Drawer>
|
||
|
||
<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>
|
||
|
||
<Modal title={<Space><GiftOutlined />积分充值</Space>} open={rechargeModalOpen}
|
||
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||
width={680}
|
||
className="recharge-modal"
|
||
footer={
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '12px 0 0', borderTop: '1px solid #f0f0f0' }}>
|
||
<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) {
|
||
const paymentInfo = {
|
||
price: plan.price,
|
||
credits: totalCredits,
|
||
qrCode: qrCode,
|
||
method: order.paymentMethod,
|
||
};
|
||
setCurrentPaymentInfo(paymentInfo);
|
||
setRechargeModalOpen(false);
|
||
setQrCodeModalOpen(true);
|
||
currentOrderNoRef.current = order.orderNo;
|
||
|
||
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,
|
||
}));
|
||
|
||
startPolling(order.orderNo);
|
||
} else {
|
||
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 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>
|
||
|
||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||
<div style={{ marginBottom: 16, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||
⚠️ 暂无可用的支付方式,请联系管理员开启支付功能
|
||
</Typography.Text>
|
||
</div>
|
||
) : (
|
||
<div style={{ marginBottom: 16 }}>
|
||
<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={{
|
||
padding: '12px 16px',
|
||
background: 'rgba(99, 102, 241, 0.06)',
|
||
borderRadius: 10,
|
||
marginBottom: 16,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
}}>
|
||
<InfoOutlined style={{ color: '#6366f1', fontSize: 14 }} />
|
||
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>
|
||
当前平台仅支持支付宝/微信扫码充值,如需转账支付请
|
||
<Typography.Text
|
||
style={{
|
||
color: '#ff0000ff',
|
||
cursor: 'pointer',
|
||
textDecoration: 'underline',
|
||
}}
|
||
onClick={() => { setRechargeModalOpen(false); setContactModalOpen(true); }}
|
||
>联系我们</Typography.Text>
|
||
</Typography.Text>
|
||
</div>
|
||
<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: 14, fontWeight: 600, color: '#6366f1' }}>{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>
|
||
</div>
|
||
</Modal>
|
||
|
||
<Modal
|
||
open={qrCodeModalOpen}
|
||
onCancel={async () => {
|
||
stopPolling();
|
||
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' }}>
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<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 />
|
||
|
||
<div
|
||
className="contact-button-wrapper"
|
||
style={{
|
||
right: `${contactPosition.x}px`,
|
||
bottom: `${window.innerHeight - contactPosition.y}px`,
|
||
}}
|
||
>
|
||
<div style={{
|
||
position: 'relative',
|
||
}}>
|
||
<div
|
||
className="contact-tooltip"
|
||
style={{
|
||
opacity: contactHovered ? 1 : 0,
|
||
}}
|
||
>
|
||
联系我们
|
||
</div>
|
||
<button
|
||
className={`contact-button ${isDragging ? 'dragging' : ''}`}
|
||
onMouseEnter={() => setContactHovered(true)}
|
||
onMouseLeave={() => setContactHovered(false)}
|
||
onMouseDown={handleContactMouseDown}
|
||
onMouseUp={handleContactMouseUp}
|
||
>
|
||
<MessageOutlined style={{ fontSize: 20 }} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<Modal
|
||
title={<Space><MessageOutlined />联系我们</Space>}
|
||
open={contactModalOpen}
|
||
onCancel={() => { setContactModalOpen(false); contactForm.resetFields(); }}
|
||
footer={null}
|
||
width={480}
|
||
className="contact-modal"
|
||
>
|
||
<div style={{ marginTop: 8 }}>
|
||
<Form form={contactForm} layout="vertical">
|
||
<Form.Item
|
||
name="name"
|
||
label="姓名"
|
||
rules={[{ required: true, message: '请输入姓名' }]}
|
||
>
|
||
<Input placeholder="请输入您的姓名" size="large" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="phone"
|
||
label="手机号"
|
||
rules={[
|
||
{ required: true, message: '请输入手机号' },
|
||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
|
||
]}
|
||
>
|
||
<Input placeholder="请输入您的手机号" size="large" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="companyName"
|
||
label="公司名称"
|
||
rules={[{ required: true, message: '请输入公司名称' }]}
|
||
>
|
||
<Input placeholder="请输入公司名称" size="large" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="industry"
|
||
label="您的行业"
|
||
rules={[{ required: true, message: '请输入您的行业' }]}
|
||
>
|
||
<Input placeholder="请输入您的行业" size="large" />
|
||
</Form.Item>
|
||
<Form.Item name="message" label="留言(选填)">
|
||
<Input.TextArea
|
||
placeholder="请输入您的需求或问题"
|
||
rows={3}
|
||
style={{ borderRadius: 10 }}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
<div style={{ marginTop: 16, display: 'flex', gap: 12 }}>
|
||
<Button
|
||
size="large"
|
||
onClick={() => { setContactModalOpen(false); contactForm.resetFields(); }}
|
||
style={{ borderRadius: 10, flex: 1 }}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
onClick={handleContactSubmit}
|
||
loading={submittingContact}
|
||
style={{
|
||
borderRadius: 10,
|
||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||
border: 'none',
|
||
flex: 1,
|
||
}}
|
||
>
|
||
提交
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default AppLayout;
|