2115 lines
94 KiB
TypeScript
2115 lines
94 KiB
TypeScript
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;
|
||
|
||
// 单位:后端返回的 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 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、模型专属积分有效期升级:2026年8月7日(含)起购买,有效期为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;
|