Files
video-gen/video-gen-app/src/components/Layout/AppLayout.tsx
T
2026-08-13 11:54:26 +08:00

2115 lines
94 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tag, Button, Typography, Radio, Drawer, Tabs, Progress, Collapse, ConfigProvider } from 'antd';
import { QRCodeSVG } from 'qrcode.react';
import {
PlayCircleOutlined,
WalletOutlined,
LogoutOutlined,
UserOutlined,
ThunderboltOutlined,
HomeOutlined,
DashboardOutlined,
CodeOutlined,
LockOutlined,
PlusOutlined,
GiftOutlined,
BellOutlined,
SettingOutlined,
AppstoreOutlined,
FileTextOutlined,
StarOutlined,
HeartOutlined,
CameraOutlined,
CloudOutlined,
SmileOutlined,
TrophyOutlined,
RocketOutlined,
BulbOutlined,
PictureOutlined,
VideoCameraOutlined,
AudioOutlined,
MailOutlined,
PhoneOutlined,
GlobalOutlined,
TeamOutlined,
BarChartOutlined,
PieChartOutlined,
LineChartOutlined,
SecurityScanOutlined,
ApiOutlined,
DatabaseOutlined,
CloudServerOutlined,
MessageOutlined,
MenuOutlined,
RobotOutlined,
ProfileOutlined,
CloseOutlined,
CrownOutlined,
CrownFilled,
FireOutlined,
BankFilled,
CheckCircleFilled,
WechatOutlined,
AlipayCircleOutlined,
ShoppingCartOutlined,
DownOutlined,
UpOutlined,
RightOutlined,
StarFilled,
FireFilled,
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
import { getMenuConfigs, getCreditProductCatalog, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest, getUser, changePassword, changeUsername } from '../../api';
import type { CreditProduct, CreditProductCatalog } from '../../types';
import NotificationPopup from '../NotificationPopup';
import ActivityBanner from './ActivityBanner';
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;
// 单位:后端返回的 limitUnitKB / 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 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,
marginBottom: 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={{
marginTop: 4,
display: 'flex',
justifyContent: 'space-between',
fontSize: 11,
color: isOver ? '#ef4444' : '#94a3b8',
}}
>
<div style={{
height: '100%',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
fontSize: 12,
color: isOver ? '#000000ff' : '#000000ff',
padding: '0 8px',
}}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<DatabaseOutlined style={{ fontSize: 12, color: isOver ? '#000000ff' : '#000000ff' }} />
{rawPercent.toFixed(1)}%
</span>
{data.enabled ? (
<span style={{ fontWeight: 500, color: isOver ? '#000000ff' : '#000000ff' }}>
{used.toFixed(2)} / {total.toFixed(2)} {unit}
</span>
) : (
<span style={{ fontWeight: 500, color: '#1e293b' }}>
当前使用 {usedAuto.val} {usedAuto.unit}
</span>
)}
</div>
{/* <span>
{isOver
? `存储超额 · 超用 ${Math.abs(available).toFixed(2)} ${unit}`
: `剩余 ${available.toFixed(2)} ${unit}`}
</span> */}
{/* <span>{rawPercent.toFixed(1)}%</span> */}
</div>
<div
style={{
width: '100%',
height: 6,
background: '#e2e8f0',
borderRadius: 50,
overflow: 'hidden',
position: 'relative',
}}
>
<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>
);
};
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: <RobotOutlined />,
GiftOutlined: <GiftOutlined />,
ThunderboltOutlined: <ThunderboltOutlined />,
CloudOutlined: <CloudOutlined />,
SmileOutlined: <SmileOutlined />,
TrophyOutlined: <TrophyOutlined />,
RocketOutlined: <RocketOutlined />,
BulbOutlined: <BulbOutlined />,
PictureOutlined: <PictureOutlined />,
VideoCameraOutlined: <VideoCameraOutlined />,
AudioOutlined: <AudioOutlined />,
MailOutlined: <MailOutlined />,
PhoneOutlined: <PhoneOutlined />,
GlobalOutlined: <GlobalOutlined />,
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 MEMBERSHIP_GRADIENTS = [
{ gradient: 'linear-gradient(135deg, #f6ad55, #dd6b20)', shadow: 'rgba(246,173,85,0.25)', icon: <CrownOutlined /> },
{ gradient: 'linear-gradient(135deg, #667eea, #5a67d8)', shadow: 'rgba(102,126,234,0.25)', icon: <CrownOutlined /> },
{ gradient: 'linear-gradient(135deg, #94a3b8, #64748b)', shadow: 'rgba(148,163,184,0.25)', icon: <StarFilled /> },
{ gradient: 'linear-gradient(135deg, #c9a96e, #a67c52)', shadow: 'rgba(201,169,110,0.25)', icon: <StarFilled /> },
{ gradient: 'linear-gradient(135deg, #718096, #4a5568)', shadow: 'rgba(113,128,150,0.25)', icon: <CrownOutlined /> },
{ gradient: 'linear-gradient(135deg, #5a67d8, #434190)', shadow: 'rgba(90,103,216,0.25)', icon: <BankFilled /> },
];
const FAQ_ITEMS = [
{
key: 'points-validity',
label: '积分有效期规则',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: '0 0 8px' }}>1、会员积分:月卡与年卡的积分配额均按月发放,自到账起 31 天内有效,到期自动重置(上周期未使用积分清零 + 下发下周期度积分);</p>
<p style={{ margin: '0 0 8px' }}>2、充值通用积分:自到账起 2 年内有效,到期清零,不退不换;</p>
<p style={{ margin: '0 0 8px' }}>3、模型专属积分有效期升级:202687日(含)起购买,有效期为2年;此前购买,有效期为6个月。有效期自积分到账之日起计算,到期清零,不退不换;</p>
<p style={{ margin: 0 }}>4、每日登录积分:赠送 20 积分,仅限当日使用,次日自动清零。</p>
</div>
),
},
{
key: 'refund-rules',
label: '会员&积分 退款规则',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: 0 }}>会员与积分属于虚拟数字商品,开通后权益即时生效,因此一经购买不支持任何理由的退款或转让。建议您在支付前确认所选内容,如遇重复扣款或系统异常,请联系客服,我们会尽快为您处理。感谢理解 💛</p>
</div>
),
},
{
key: 'points-return',
label: '积分返还规则',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: 0 }}>若生成分成失败,被扣的对应积分将在 2 小时内返还至原账户,在「积分明细页」原消耗记录条目中会展示"任务生成失败,积分已返还"提示。因个别原因导致延迟或未返还的情况,可联系平台客服进行处理。</p>
</div>
),
},
{
key: 'points-consumption',
label: '积分消耗顺序',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: '0 0 8px', color: '#475569', fontWeight: 500 }}>积分消耗顺序规则:分为平台默认顺序、用户自定义顺序。</p>
<p style={{ margin: '0 0 8px' }}>1、平台默认消耗顺序:免费积分 模型专享积分 订阅会员积分 通用充值类积分;</p>
<p style={{ margin: '0 0 8px', paddingLeft: 12 }}>其中,免费积分包含 赠送的TV专属积分、赠送的指定模型专享积分、每日登录奖励积分等。若同一类型中的积分包含多个细分子类型,子类型之间的消耗顺序按到期时间,优先消耗早到期的;</p>
<p style={{ margin: '0 0 8px' }}>2、自定义积分消耗顺序:用户可在「个人中心-充值入口」、「模型购买页面」、「积分明细页」自主设置积分的消耗顺序。</p>
<p style={{ margin: 0, color: '#ef4444' }}>特别注意:模型赠送的限时免费生成次数,会优先于积分被最先使用。</p>
</div>
),
},
{
key: 'get-more-points',
label: '如何获取更多积分',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: '0 0 8px' }}>若当前积分不足,可通过以下方式补充:</p>
<p style={{ margin: '0 0 4px' }}>- 升级会员:购买后立即生效,积分即时到账;</p>
<p style={{ margin: '0 0 4px' }}>- 单独充值通用积分:按需购买,灵活补充;</p>
<p style={{ margin: 0 }}>- 单独充值模型专享积分:随用随充,性价比高,适用于对特定模型重度使用的用户。</p>
</div>
),
},
{
key: 'installment',
label: '关于分期支付',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: 0 }}>部分商品支持花呗分期,目前分期产生的利息由平台承担,用户限时享受0利息。使用分期支付的商品不支持退款,如有逾期还款或产生退费,平台将收取相应费用。</p>
</div>
),
},
{
key: 'invoice',
label: '发票申请与联系方式',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: 0 }}>发票可在「订阅与开票」→「购买记录」中自助申请。如需企业合作,请联系 bd@liblib.ai;其他问题欢迎前往"帮助中心"查询。</p>
</div>
),
},
{
key: 'price-protection',
label: '会员权益7天保护计划',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: '0 0 8px' }}>7天内购买的会员,若遇同档位会员有新活动,且赠品力度更高,可申请按差额补发赠品。</p>
<p style={{ margin: '0 0 4px' }}>1、保护范围:指定模型的免费生成次数、专享积分赠送(会员)。</p>
<p style={{ margin: '0 0 4px' }}>2、保护条件:支付成功 7 天,且会员仍在有效期内,仅限同档位、同周期、同类型赠品。每笔订单针对同一模型赠品,仅限申请 1 次;</p>
<p style={{ margin: '0 0 4px' }}>3、权益有效期:补发的权益与原会员同效期,到期未自动清零,不可退款、转让。</p>
<p style={{ margin: '0 0 4px' }}>4、不参与保护范围:会员订单价格本身(即不退会员差价);已下线/已结束的活动;通过平台赠送、邀请奖励等非现金方式开通的会员;</p>
<p style={{ margin: 0 }}>5、特别注意:如未在保护有效期内主动领取保护权益,过期将不会补发;Happy Horse 1.0模型不参与保护。</p>
</div>
),
},
{
key: 'free-points',
label: '免积分畅享卡 优惠活动如何执行的?',
children: (
<div style={{ color: '#64748b', lineHeight: 1.8, fontSize: 13 }}>
<p style={{ margin: '0 0 8px' }}>免积分权益可让您在权益有效期内,使用对应模型生成不消耗积分,同一模型同时最多运行 1 个免积分任务,高峰期生成可能排队,排队时间受到使用人数、高峰时段、模型后台资源情况、用户当日已生成数量等多种因素有关。</p>
<p style={{ margin: '0 0 4px', color: '#475569', fontWeight: 500 }}>具体规则如下:</p>
<p style={{ margin: '0 0 4px' }}>- 独立于订阅周期:免限权益按固定期限计算,与订阅周期无关。无论订阅自动续费、取消还是重新开通,都不会重置或延长该权益的有效期。</p>
<p style={{ margin: '0 0 4px' }}>- 有效期内升级套餐:如果您在免积分权益仍然有效期内升级套餐,将立即解锁更高等级套餐对应的模型能力,并刷新您的有效期。</p>
<p style={{ margin: 0 }}>- 合理使用与系统保护:免积分权益仅支持真人手动操作,暂不支持 CLI 和其他自动化脚本等生成。为了保障 GPU 集群稳定运行,如检测到自动化脚本、机器人操作或非真人高频使用行为,系统可能会暂时暂停您的权限进行审核。确认是真人创作者后,将恢复访问权限。</p>
</div>
),
},
];
const AppLayout: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { user, logout, refreshUser } = useAuthStore();
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [contactModalOpen, setContactModalOpen] = useState(false);
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
const [pwdForm] = Form.useForm();
const [usernameForm] = Form.useForm();
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
const [rechargeOptions, setRechargeOptions] = useState<CreditProduct[]>([]);
const [subscriptionProducts, setSubscriptionProducts] = useState<CreditProduct[]>([]);
const [currentSubscription, setCurrentSubscription] = useState<CreditProductCatalog['currentSubscription']>(null);
const loadCreditCatalog = useCallback(async () => {
try {
const data = await getCreditProductCatalog();
setSubscriptionProducts((data.subscriptionProducts || []).filter((product) => product.isActive));
setRechargeOptions((data.creditAddons || []).filter((product) => product.isActive));
setCurrentSubscription(data.currentSubscription || null);
} catch {
setSubscriptionProducts([]);
setRechargeOptions([]);
setCurrentSubscription(null);
}
}, []);
const [creditsModalOpen, setCreditsModalOpen] = useState(false);
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'quarterly' | 'yearly'>('monthly');
const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false);
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 });
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{
price: number;
credits: number;
qrCode: string;
method: string;
title: string;
originalPrice?: number;
discountAmount?: number;
tierName?: string;
periodLabel?: string;
isSubscription?: boolean;
} | null>(null);
const PENDING_ORDER_KEY = 'pending_payment_order';
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 [bannerVisible, setBannerVisible] = useState(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 [operationManualUrl, setOperationManualUrl] = useState('');
// 监听"被踢出"事件 — 单设备登录互斥,跳转登录页并带上标识
useEffect(() => {
const handleKickedOut = () => {
localStorage.removeItem('token');
navigate('/login?kicked_out=1');
};
window.addEventListener('kicked-out', handleKickedOut);
return () => window.removeEventListener('kicked-out', handleKickedOut);
}, [navigate]);
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);
}
// 相对路径拼接 API 基础地址,确保 favicon 正确加载
const base = import.meta.env.VITE_API_BASE || '';
const fullUrl = logo.startsWith('http') || logo.startsWith('data:') ? logo : `${base}${logo.startsWith('/') ? '' : '/'}${logo}`;
faviconLink.href = fullUrl;
faviconLink.type = 'image/png';
}
if (info.operationManual) {
setOperationManualUrl(info.operationManual);
}
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(() => {
// 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(() => {
getMenuConfigs().then(data => {
const items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
setMenuItems(items);
}).catch(() => { });
loadCreditCatalog();
getPaymentMethods().then(data => {
setEnabledMethods(data);
if (data.alipay) setPaymentMethod('alipay');
else if (data.wechat) setPaymentMethod('wechat');
}).catch(() => { });
}, []);
useEffect(() => {
if (user) {
loadUnreadCount();
}
}, [user?.id, user?.credits]);
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,
title: savedOrder.title || '会员服务',
});
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();
}, []);
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 },
...(user?.isTeamManager ? [{ key: 'teamManagement' as const, icon: <TeamOutlined style={{ color: '#6366f1' }} />, label: '团队管理' }] : []),
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
{ key: 'invoice', icon: <ProfileOutlined />, label: '申请开票' },
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
...(operationManualUrl ? [{ key: 'manual' as const, icon: <FileTextOutlined />, 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 === 'teamManagement') { navigate('/team-management'); }
else if (key === 'myCredits') { navigate('/user-center?tab=credits'); }
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
else if (key === 'invoice') { navigate('/invoice'); }
else if (key === 'manual') { window.open(operationManualUrl, '_blank'); }
};
const handleChangePwd = async () => {
try {
const values = await pwdForm.validateFields();
await changePassword(values.oldPwd, values.newPwd);
message.success('密码修改成功');
setPwdModalOpen(false);
pwdForm.resetFields();
} catch (error: any) {
if (error?.response?.data?.detail) {
message.error(error.response.data.detail);
} else if (error?.message) {
message.error(error.message);
}
}
};
const handleChangeUsername = async () => {
try {
const values = await usernameForm.validateFields();
const result = await changeUsername(values.username);
message.success(result.message || '操作成功');
await refreshUser();
getUser().then((res: any) => {
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(() => { });
setPwdModalOpen(false);
usernameForm.resetFields();
} catch (error: any) {
if (error?.response?.data?.message) {
message.error(error.response.data.message);
} else if (error?.response?.data?.detail) {
message.error(error.response.data.detail);
} else if (error?.message) {
message.error(error.message);
}
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
const stopPolling = useCallback(() => {
if (pollingTimerRef.current) {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
}, []);
const handleDirectPurchase = useCallback(async (
productId: string,
product: { name?: string; price?: number; currentPrice?: number; monthlyGrantCredits?: number | string; credits?: number; grantCredits?: number; regularPrice?: number; originalPrice?: number; billingCycle?: string; tierCode?: string; description?: string; }
) => {
if (!enabledMethods.alipay && !enabledMethods.wechat) {
message.error('暂无可用支付方式');
return;
}
setPaying(true);
try {
const order = await createRechargeOrder(productId, paymentMethod);
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
const finalPrice = Number(order.amount ?? product.currentPrice ?? product.price ?? 0);
const credits = Number(product.grantCredits || product.monthlyGrantCredits || 0);
const originalPrice = product.regularPrice ?? product.originalPrice;
const cycleMap: Record<string, string> = { monthly: '月套餐', quarterly: '季套餐', yearly: '年套餐' };
const isSubscription = !!product.billingCycle;
const periodLabel = product.billingCycle ? cycleMap[product.billingCycle] : '';
const tierName = product.name || '积分充值';
const displayTitle = isSubscription ? `${tierName} ${periodLabel}` : tierName;
const paymentInfo = {
price: finalPrice,
credits,
qrCode,
method: order.paymentMethod,
title: displayTitle,
originalPrice: originalPrice ? Number(originalPrice) : undefined,
discountAmount: originalPrice ? Number(originalPrice) - finalPrice : undefined,
tierName,
periodLabel,
isSubscription,
};
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
setCurrentPaymentInfo(paymentInfo);
setCreditsModalOpen(false);
setQrCodeModalOpen(true);
currentOrderNoRef.current = order.orderNo;
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
orderNo: order.orderNo,
...paymentInfo,
createdAt: order.createdAt || new Date().toISOString(),
timeoutSeconds: 180,
}));
startPolling(order.orderNo);
} else {
message.success('购买成功,积分已到账');
await useAuthStore.getState().refreshUser();
await loadCreditCatalog();
setSelectedPlan(null);
}
} catch (err: any) {
message.error(err?.message || '创建订单失败');
} finally {
setPaying(false);
}
}, [paymentMethod, enabledMethods]);
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();
loadCreditCatalog();
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;
}, [loadCreditCatalog, stopPolling]);
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 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={{
height: '100vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
<ActivityBanner onVisibilityChange={(v) => setBannerVisible(v)} />
<div style={{ display: 'flex', position: 'relative', padding: 4, gap: 12, flex: 1, minHeight: 0, overflow: 'hidden' }}>
<div className="desktop-sidebar" style={{
width: sidebarW, position: 'sticky', top: 4, alignSelf: 'flex-start', zIndex: 100,
height: '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',
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: 36, height: 36, 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 className="user-card-container" style={{
padding: '10px 14px',
display: 'flex', alignItems: 'center',
justifyContent: 'flex-start',
gap: 12,
borderRadius: 14, cursor: 'pointer',
transition: 'all 0.35s cubic-bezier(0.4,0,0.2,1)',
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.95) 0%, rgba(241, 245, 249, 0.95) 100%)',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255,255,255,0.8)',
position: 'relative',
border: '1px solid rgba(99, 102, 241, 0.15)',
animation: 'cardBreath 3s ease-in-out infinite',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)';
e.currentTarget.style.boxShadow = '0 8px 24px rgba(99, 102, 241, 0.25), inset 0 1px 0 rgba(255,255,255,0.9)';
e.currentTarget.style.transform = 'translateY(-3px) scale(1.01)';
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.4)';
e.currentTarget.style.animation = 'none';
e.currentTarget.querySelector('.user-card-arrow')?.classList.add('arrow-hover');
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(248, 250, 252, 0.95) 0%, rgba(241, 245, 249, 0.95) 100%)';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255,255,255,0.8)';
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.borderColor = 'rgba(99, 102, 241, 0.15)';
e.currentTarget.style.animation = 'cardBreath 3s ease-in-out infinite';
e.currentTarget.querySelector('.user-card-arrow')?.classList.remove('arrow-hover');
}}
>
<div className="user-card-glow" style={{
position: 'absolute',
inset: -4,
borderRadius: 18,
background: 'linear-gradient(90deg, transparent 0%, rgba(99,102,241,0.15) 25%, rgba(139,92,246,0.15) 50%, rgba(99,102,241,0.15) 75%, transparent 100%)',
backgroundSize: '200% 100%',
opacity: 0.8,
animation: 'borderShimmer 2.5s linear infinite',
pointerEvents: 'none',
}} />
<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)',
transition: 'all 0.35s cubic-bezier(0.4,0,0.2,1)',
position: 'relative',
zIndex: 1,
animation: 'avatarPulse 2s ease-in-out infinite',
}} />
<div style={{ flex: 1, minWidth: 0, position: 'relative', zIndex: 1 }}>
<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: 14,
fontWeight: 500,
letterSpacing: 0,
borderRadius: 6,
display: 'inline-block',
animation: 'creditGlow 1.5s ease-in-out infinite',
}}>积分: {user?.credits || 0}</div>
</div>
<div className="user-card-arrow" style={{
fontSize: 12,
color: '#6366f1',
flexShrink: 0,
position: 'relative',
zIndex: 1,
}}>
<MenuOutlined />
</div>
<style>{`
@keyframes arrowFlash {
0%, 100% {
transform: translateY(0) scale(1);
opacity: 0.6;
filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.4));
}
50% {
transform: translateY(4px) scale(1.1);
opacity: 1;
filter: drop-shadow(0 0 12px rgba(99, 102, 241, 0.7));
}
}
@keyframes creditGlow {
0%, 100% {
text-shadow: 0 0 0 transparent;
}
50% {
text-shadow: 0 0 15px rgba(99, 102, 241, 0.6), 0 0 30px rgba(99, 102, 241, 0.3);
}
}
@keyframes borderShimmer {
0% { background-position: 200% center; }
100% { background-position: -200% center; }
}
@keyframes cardBreath {
0%, 100% {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04), inset 0 1px 0 rgba(255,255,255,0.8);
border-color: rgba(99, 102, 241, 0.15);
}
50% {
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.1), inset 0 1px 0 rgba(255,255,255,0.8);
border-color: rgba(99, 102, 241, 0.25);
}
}
@keyframes avatarPulse {
0%, 100% {
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
transform: scale(1);
}
50% {
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
transform: scale(1.02);
}
}
.user-card-arrow.arrow-hover {
color: #8b5cf6 !important;
transform: rotate(180deg) scale(1.15) !important;
animation: none !important;
filter: drop-shadow(0 0 15px rgba(139, 92, 246, 0.8)) !important;
}
`}</style>
</div>
</Dropdown>
</div>
</div>
<div className="desktop-content" style={{
flex: 1,
background: '#f1f2f3',
borderRadius: '20px',
boxShadow: '0 4px 32px rgba(0, 0, 0, 0.06), 0 1px 8px rgba(0, 0, 0, 0.04)',
padding: '24px 32px 32px',
border: '1px solid rgba(0, 0, 0, 0.06)',
overflow: 'auto',
minHeight: 0,
}}>
{!isMobile && <Outlet context={{ openContactModal: () => setContactModalOpen(true) }} />}
</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 context={{ openContactModal: () => setContactModalOpen(true) }} />}
</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}
size={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><SettingOutlined />账号设置</Space>} open={pwdModalOpen}
onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); usernameForm.resetFields(); }}
width={440}
footer={null}>
<Tabs defaultActiveKey="profile">
<Tabs.TabPane tab="个人信息" key="profile">
<Form form={usernameForm} layout="vertical" style={{ marginTop: 20 }} onValuesChange={() => { }}>
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }, { min: 3, message: '用户名至少3位' }]}>
<Input placeholder="请输入用户名" size="large" prefix={<UserOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
</Form.Item>
<Form.Item>
<Button type="primary" size="large" onClick={handleChangeUsername} style={{ width: '100%' }}>确认修改</Button>
</Form.Item>
</Form>
</Tabs.TabPane>
<Tabs.TabPane tab="修改密码" key="password">
<Form form={pwdForm} layout="vertical" style={{ marginTop: 20 }} onValuesChange={() => { }}>
<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.Item>
<Button type="primary" size="large" onClick={handleChangePwd} style={{ width: '100%' }}>确认修改</Button>
</Form.Item>
</Form>
</Tabs.TabPane>
</Tabs>
</Modal>
{/* 充值全屏弹窗 */}
<ConfigProvider
theme={{
token: {
colorPrimary: '#6366f1',
borderRadius: 8,
},
}}
>
<Modal
open={rechargeModalOpen}
onCancel={() => setRechargeModalOpen(false)}
footer={null}
width="100%"
centered={false}
style={{ top: 0, maxWidth: '100vw', margin: 0, paddingBottom: 0 }}
styles={{
body: { padding: 0, height: '100vh', overflow: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none' },
mask: { background: 'rgba(0,0,0,0.45)' },
header: { display: 'none' },
}}
closable
className="recharge-fullscreen-modal"
title={null}
closeIcon={<CloseOutlined style={{ fontSize: 18 }} />}
>
<div style={{
width: '100%',
minHeight: '100vh',
background: '#f8fafc',
position: 'relative',
}}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '24px' }}>
{/* 订阅套餐区 */}
<div style={{ marginBottom: 24 }}>
<div style={{ textAlign: 'center', marginBottom: 20 }}>
<Typography.Title level={3} style={{ marginBottom: 8 }}>订阅套餐</Typography.Title>
<Typography.Text type="secondary">订阅积分按自然月逐月发放;有效订阅只能升级同周期更高等级套餐,不能提前续费。</Typography.Text>
</div>
{currentSubscription && (
<div style={{ marginBottom: 16, padding: '14px 16px', borderRadius: 12, background: '#fff', border: '1px solid #e8eaed', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 18,
}}>
<CrownOutlined />
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14 }}>
当前订阅:{currentSubscription.tierCode || '订阅套餐'}
</Typography.Text>
<Tag color="purple" style={{ fontSize: 11, margin: 0, borderRadius: 6 }}>
{currentSubscription.billingCycle === 'monthly' ? '月' : currentSubscription.billingCycle === 'quarterly' ? '季' : '年'}
</Tag>
</div>
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 2 }}>
已发放 <span style={{ color: '#6366f1', fontWeight: 600 }}>{currentSubscription.grantedCount}</span>/{currentSubscription.grantCount}
· 每月 <span style={{ color: '#6366f1', fontWeight: 600 }}>{Number(currentSubscription.monthlyGrantCredits || 0).toLocaleString()}</span> 积分
</div>
<div style={{ fontSize: 11, color: '#94a3b8' }}>
订阅到期:{new Date(currentSubscription.expiresAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}
</div>
</div>
<Progress
percent={Math.round(((currentSubscription.grantedCount || 0) / (currentSubscription.grantCount || 1)) * 100)}
size="small"
strokeColor={{ from: '#6366f1', to: '#8b5cf6' }}
style={{ width: 80 }}
/>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 16, marginBottom: 20 }}>
<Radio.Group value={selectedPeriod} onChange={(e) => { setSelectedPeriod(e.target.value); setSelectedPlan(null); }} buttonStyle="solid">
<Radio.Button value="monthly">月套餐</Radio.Button>
<Radio.Button value="quarterly">季套餐</Radio.Button>
<Radio.Button value="yearly">年套餐</Radio.Button>
</Radio.Group>
</div>
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).length === 0 ? (
<div style={{ padding: 48, textAlign: 'center', color: '#94a3b8' }}>暂无可订阅套餐,请联系管理员配置并上架套餐。</div>
) : (
<div style={{ display: 'flex', justifyContent: 'center', gap: 16, flexWrap: 'wrap' }}>
{subscriptionProducts.filter((item) => item.billingCycle === selectedPeriod).map((product, index) => {
const gradient = MEMBERSHIP_GRADIENTS[index % MEMBERSHIP_GRADIENTS.length];
const originalPrice = product.regularPrice || product.price;
const currentPrice = product.currentPrice ?? product.price;
const hasDiscount = originalPrice && Number(originalPrice) > Number(currentPrice);
const creditsLabel = product.monthlyGrantCredits
? `${Number(product.monthlyGrantCredits).toLocaleString()} 积分/月`
: `${Number(product.grantCredits || 0).toLocaleString()} 积分`;
return (
<div key={product.id} style={{
width: 250,
display: 'flex', flexDirection: 'column',
borderRadius: 14, overflow: 'hidden',
background: '#fff',
border: '1px solid #e8eaed',
position: 'relative',
transition: 'all 0.2s',
}}>
<div style={{ padding: '16px 14px 12px', background: '#fafbfc', flex: 1 }}>
{product.priceType === 'first_purchase' && (
<div style={{ position: 'absolute', top: 8, right: 8, background: '#fff2e8', color: '#fa541c', fontSize: 11, padding: '2px 8px', borderRadius: 10, fontWeight: 500 }}>首充价</div>
)}
{product.priceType === 'activity' && (
<div style={{ position: 'absolute', top: 8, right: 8, background: '#fff1f0', color: '#cf1322', fontSize: 11, padding: '2px 8px', borderRadius: 10, fontWeight: 500 }}>活动价</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: gradient.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 14 }}>{gradient.icon}</div>
<div>
<Typography.Text strong style={{ fontSize: 14 }}>{product.name}</Typography.Text>
<div style={{ fontSize: 11, color: '#94a3b8' }}>{product.description || product.tierCode}</div>
</div>
</div>
<div style={{ marginBottom: 8 }}>
<span style={{ fontSize: 24, fontWeight: 700, color: '#1a1a2e' }}>¥{currentPrice}</span>
{hasDiscount && (
<span style={{ fontSize: 13, color: '#94a3b8', textDecoration: 'line-through', marginLeft: 6 }}>¥{originalPrice}</span>
)}
{product.canUpgrade && (
<Tag color="orange" style={{ marginLeft: 6, fontSize: 10, padding: '0 6px', lineHeight: '16px' }}>升级价</Tag>
)}
</div>
{product.canUpgrade && Number(product.deductionAmount || 0) > 0 && (
<div style={{ fontSize: 11, color: '#94a3b8', marginBottom: 4 }}>
目标套餐¥{product.targetPrice},抵扣未生效¥{product.deductionAmount}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 12, color: '#64748b' }}>
<span>{creditsLabel}</span>
<span> {product.grantCount} </span>
</div>
{product.unavailableReason && (
<div style={{ padding: '6px 14px', fontSize: 13, color: '#dc2626', background: '#fef2f2', marginTop: '20px' }}>
{product.unavailableReason}
</div>
)}
<div style={{ padding: '10px 14px', borderTop: '1px solid #f0f0f0' }}>
<Button
block
size="small"
disabled={product.canPurchase === false || (!enabledMethods.alipay && !enabledMethods.wechat)}
loading={paying && selectedPlan === product.id}
onClick={() => {
setSelectedPlan(product.id);
handleDirectPurchase(product.id, product);
}}
style={{
borderRadius: 8,
background: product.canPurchase === false ? '#f5f5f5' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: product.canPurchase === false ? '#94a3b8' : '#fff',
border: 'none',
fontWeight: 600,
height: 34,
fontSize: 13,
}}
>
{product.canPurchase === false ? '不可购买' : '立即订阅'}
</Button>
</div>
{product.features && product.features.length > 0 && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px dashed #e2e8f0' }}>
{product.features.map((feature: string, fi: number) => (
<div key={fi} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b', lineHeight: '20px', marginBottom: 2 }}>
<CheckCircleFilled style={{ color: '#4c49cc', fontSize: 14, flexShrink: 0, marginTop: 2 }} />
<span style={{ fontSize: 14, margin: '4px' }}>{feature}</span>
</div>
))}
</div>
)}
{(product.priceType === 'first_purchase' || product.priceType === 'activity') && (
<div style={{ padding: '10px 14px', background: '#fffbe6', borderTop: '1px solid #fff1b8' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<FireOutlined style={{ color: '#fa8c16', fontSize: 12 }} />
<span style={{ fontSize: 12, color: '#d46b08', fontWeight: 600 }}>限时活动</span>
</div>
{product.features?.slice(0, 3).map((feature: string, fi: number) => (
<div key={fi} style={{ fontSize: 11, color: '#8c6d1f', lineHeight: '18px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
· {feature}
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 积分充值入口 */}
<div style={{ marginBottom: 24, background: '#fff', borderRadius: 12, padding: 24, border: '1px solid #e8eaed' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<WalletOutlined style={{ color: '#6366f1', fontSize: 24 }} />
<div>
<Typography.Title level={4} style={{ margin: 0 }}>积分充值</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>当前积分:<span style={{ color: '#6366f1', fontWeight: 600 }}>{user?.credits ?? 0}</span></Typography.Text>
</div>
</div>
<Button
type="primary"
size="large"
icon={<ShoppingCartOutlined />}
onClick={() => setCreditsModalOpen(true)}
style={{
borderRadius: 10,
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
border: 'none',
height: 42,
paddingLeft: 20,
paddingRight: 20,
}}
>
积分商城
</Button>
</div>
</div>
{/* 常见问题 - 浅色背景 */}
<div style={{ marginBottom: 24, borderRadius: 16, overflow: 'hidden', background: '#fff', border: '1px solid #e8eaed' }}>
<div style={{ padding: '24px 24px 0', textAlign: 'center' }}>
<Typography.Title level={3} style={{ color: '#1a1a2e', marginBottom: 16 }}>常见问题</Typography.Title>
</div>
<Collapse
accordion
expandIcon={({ isActive }) => isActive ? <UpOutlined style={{ color: '#64748b' }} /> : <DownOutlined style={{ color: '#64748b' }} />}
style={{ background: '#fff', border: 'none' }}
items={FAQ_ITEMS.map(item => ({
key: item.key,
label: <span style={{ color: '#1a1a2e', fontSize: 14, fontWeight: 500 }}>{item.label}</span>,
children: item.children,
style: { background: '#fff', borderBottom: '1px solid #e8eaed' },
}))}
/>
{/* 联系客服按钮 */}
<div style={{
padding: '24px',
borderTop: '1px solid #e8eaed',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onClick={() => { setContactModalOpen(true); }}
onMouseEnter={(e) => { e.currentTarget.style.background = '#f8fafc'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = '#fff'; }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<MessageOutlined style={{ color: '#64748b', fontSize: 18 }} />
<span style={{ color: '#1a1a2e', fontSize: 14, fontWeight: 500 }}>联系客服</span>
</div>
<RightOutlined style={{ color: '#64748b', fontSize: 14 }} />
</div>
</div>
</div>
</div>
</Modal>
</ConfigProvider>
{/* 积分商城弹窗 */}
<Modal
title={<Space><WalletOutlined />积分商城</Space>}
open={creditsModalOpen}
onCancel={() => setCreditsModalOpen(false)}
footer={null}
width={720}
closable
styles={{ body: { padding: 20 } }}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<Space>
<Typography.Text>当前积分:</Typography.Text>
<Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text>
</Space>
</div>
{/* <div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
{enabledMethods.alipay && (
<div onClick={() => setPaymentMethod('alipay')} style={{ flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px', border: paymentMethod === 'alipay' ? '2px solid #1677ff' : '1px solid #e8eaed', color: paymentMethod === 'alipay' ? '#1677ff' : '#64748b', cursor: 'pointer', background: paymentMethod === 'alipay' ? '#f0f7ff' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, transition: 'all 0.2s' }}>
<AlipayCircleOutlined style={{ fontSize: 16 }} />支付宝
</div>
)}
{enabledMethods.wechat && (
<div onClick={() => setPaymentMethod('wechat')} style={{ flex: 1, textAlign: 'center', borderRadius: 10, height: 44, lineHeight: '42px', border: paymentMethod === 'wechat' ? '2px solid #07c160' : '1px solid #e8eaed', color: paymentMethod === 'wechat' ? '#07c160' : '#64748b', cursor: 'pointer', background: paymentMethod === 'wechat' ? '#f0fff4' : '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, transition: 'all 0.2s' }}>
<WechatOutlined style={{ fontSize: 16 }} />微信支付
</div>
)}
</div> */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, maxHeight: 400, overflowY: 'auto', paddingRight: 4 }}>
{rechargeOptions.length === 0 && <div style={{ width: '100%', padding: 32, textAlign: 'center', color: '#94a3b8' }}>暂无可购买的积分增值包</div>}
{rechargeOptions.map((opt, idx) => {
const g = GRADIENTS[idx % GRADIENTS.length];
const totalCredits = Number(opt.grantCredits || 0);
const currentPrice = opt.currentPrice || opt.price;
return (
<div key={opt.id} style={{ borderRadius: 12, overflow: 'hidden', background: '#fff', border: selectedPlan === opt.id ? '2px solid #e8eaed' : '1px solid #e8eaed', position: 'relative', transition: 'all 0.2s' }}>
{opt.description && (
<div style={{ position: 'absolute', top: -1, right: 8, background: '#fff2e8', color: '#fa541c', fontSize: 11, padding: '2px 8px', borderRadius: 10, fontWeight: 500 }}>{opt.description}</div>
)}
<div style={{ padding: '12px 10px 8px', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, flexShrink: 0, background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: '#fff' }}>{g.icon}</div>
<div>
<Typography.Text strong style={{ fontSize: 12 }}>{opt.name}</Typography.Text>
<div style={{ fontSize: 11, fontWeight: 600, color: '#6366f1' }}>{totalCredits.toLocaleString()} 积分</div>
</div>
</div>
<div style={{ marginTop: 4, fontSize: 18, fontWeight: 700, color: '#1a1a2e' }}>¥{currentPrice}</div>
<Typography.Text type="secondary" style={{ fontSize: 10 }}>有效期 {Number(opt.validityMonths || 1)} 个月</Typography.Text>
</div>
<div style={{ padding: '6px 10px', borderTop: '1px solid #f0f0f0' }}>
<Button block size="small" disabled={!enabledMethods.alipay && !enabledMethods.wechat} loading={paying && selectedPlan === opt.id} onClick={() => { setSelectedPlan(opt.id); handleDirectPurchase(opt.id, opt); }} style={{ borderRadius: 6, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', color: '#fff', border: 'none', fontWeight: 600, height: 28, fontSize: 12 }}>购买</Button>
</div>
</div>
);
})}
</div>
</Modal>
{/* QR 支付弹窗 */}
<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={
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
<Button
danger
size="large"
onClick={async () => {
stopPolling();
if (currentOrderNoRef.current) {
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
currentOrderNoRef.current = null;
}
localStorage.removeItem(PENDING_ORDER_KEY);
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
message.info('已取消支付');
}}
style={{ borderRadius: 10, minWidth: 140, fontSize: 14 }}
>
取消支付
</Button>
</div>
}
width={700}
closable
title={
<Typography.Title level={5} style={{ margin: 0 }}>
开通{currentPaymentInfo?.title || '会员服务'}{currentPaymentInfo?.credits ? ` ${Number(currentPaymentInfo.credits).toLocaleString()} 积分` : ''}
</Typography.Title>
}
styles={{
body: { padding: 0, borderRadius: 16, overflow: 'hidden' },
footer: { borderRadius: 16, borderTop: '1px solid #f0f0f0', padding: '16px 24px' },
}}
>
<div style={{ display: 'flex', minHeight: 380 }}>
<div style={{ flex: 1, padding: 24, background: '#fff', borderRight: '1px solid #f0f0f0' }}>
<div style={{ marginBottom: 20 }}>
<Typography.Text style={{ fontSize: 13, color: '#64748b', marginBottom: 8, display: 'block' }}>商品信息</Typography.Text>
<div style={{ padding: 14, background: '#f8fafc', borderRadius: 10, border: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1a1a2e' }}>
{currentPaymentInfo?.tierName || currentPaymentInfo?.title || '积分充值'}
</div>
{currentPaymentInfo?.periodLabel && (
<Tag color="purple" style={{ fontSize: 11, margin: 0, borderRadius: 6, padding: '0 6px' }}>
{currentPaymentInfo.periodLabel}
</Tag>
)}
</div>
<div style={{ fontSize: 22, fontWeight: 700, color: '#6366f1', lineHeight: 1.4 }}>
¥{currentPaymentInfo?.price || 0}
{currentPaymentInfo?.originalPrice && Number(currentPaymentInfo.originalPrice) > Number(currentPaymentInfo?.price || 0) && (
<span style={{ fontSize: 13, color: '#94a3b8', textDecoration: 'line-through', marginLeft: 8, fontWeight: 400 }}>
¥{currentPaymentInfo.originalPrice}
</span>
)}
</div>
</div>
</div>
<div>
<Typography.Text style={{ fontSize: 13, color: '#64748b', marginBottom: 8, display: 'block' }}>订单明细</Typography.Text>
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 8, border: '1px solid #e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: 12 }}>
<span style={{ color: '#64748b' }}>积分数量</span>
<span style={{ color: '#1a1a2e' }}>
{currentPaymentInfo?.credits != null ? `${Number(currentPaymentInfo.credits).toLocaleString()} 积分` : '-'}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: 12 }}>
<span style={{ color: '#64748b' }}>续费信息</span>
<span style={{ color: '#1a1a2e' }}>
{currentPaymentInfo?.isSubscription ? '次年按原价 可随时取消' : '一次性购买'}
</span>
</div>
<div style={{ height: 1, background: '#e2e8f0', margin: '8px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: 12 }}>
<span style={{ color: '#64748b' }}>商品原价</span>
<span style={{ color: '#1a1a2e' }}>¥{currentPaymentInfo?.originalPrice || currentPaymentInfo?.price || 0}.00</span>
</div>
<div style={{ height: 1, background: '#e2e8f0', margin: '8px 0' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' }}>
<span style={{ fontSize: 14, fontWeight: 500, color: '#1a1a2e' }}>应付金额</span>
<span style={{ fontSize: 18, fontWeight: 700, color: '#6366f1' }}>
¥{currentPaymentInfo?.price || 0}.00
</span>
</div>
</div>
</div>
</div>
<div style={{ width: 280, padding: 24, background: '#fff', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
{currentPaymentInfo?.method === 'alipay'
? <AlipayCircleOutlined style={{ fontSize: 18, color: '#1677ff' }} />
: <WechatOutlined style={{ fontSize: 18, color: '#07c160' }} />}
<span style={{ fontSize: 14, color: '#64748b' }}>
请用{currentPaymentInfo?.method === 'alipay' ? '支付宝' : '微信'}扫码支付
</span>
</div>
<div style={{ width: 180, height: 180, borderRadius: 12, border: '1px solid #f0f0f0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fff' }}>
{currentPaymentInfo?.qrCode && (
<QRCodeSVG value={currentPaymentInfo.qrCode} size={150} level="M" />
)}
</div>
<div style={{ marginTop: 12, fontSize: 12, color: '#94a3b8' }}>
{countdown}秒后二维码失效
</div>
<div style={{ marginTop: 8, fontSize: 11, color: '#94a3b8', textAlign: 'center' }}>
开通即代表同意<br />《用户服务协议》
</div>
</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;