1
This commit is contained in:
File diff suppressed because one or more lines are too long
+96
-96
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -28,8 +28,8 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-CyfX-OBY.js"></script>
|
<script type="module" crossorigin src="/assets/index-CzaEEfUS.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Br48Vecf.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
.desktop-sidebar {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-drawer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-item:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-item-active {
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-group {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-group:hover {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-submenu {
|
||||||
|
padding-left: 12px;
|
||||||
|
background: rgba(248, 250, 252, 0.5);
|
||||||
|
margin: 0 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-submenu-item {
|
||||||
|
padding: 10px 16px 10px 20px;
|
||||||
|
margin: 1px 0;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-recharge-item {
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-recharge-item:hover {
|
||||||
|
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-button-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
right: 64px;
|
||||||
|
bottom: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 20px rgba(99, 102, 241, 0.4);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.desktop-sidebar {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-content {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #475569;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-btn:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-credits-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-credits-badge:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-content {
|
||||||
|
display: block;
|
||||||
|
padding-top: 56px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-content > div {
|
||||||
|
margin: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||||
|
min-height: calc(100vh - 104px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-button-wrapper {
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-tooltip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-button {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recharge-modal .ant-modal {
|
||||||
|
max-width: calc(100vw - 32px) !important;
|
||||||
|
margin: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-modal .ant-modal {
|
||||||
|
max-width: calc(100vw - 32px) !important;
|
||||||
|
margin: 16px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd';
|
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio, Drawer } from 'antd';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import {
|
import {
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
@@ -56,11 +56,14 @@ import {
|
|||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
DownOutlined,
|
DownOutlined,
|
||||||
InfoOutlined,
|
InfoOutlined,
|
||||||
|
MenuOutlined,
|
||||||
|
ArrowLeftOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../../store/useAuthStore';
|
import { useAuthStore } from '../../store/useAuthStore';
|
||||||
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest } from '../../api';
|
import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getSiteInfo, getUnreadCount, createContactRequest } from '../../api';
|
||||||
import NotificationPopup from '../NotificationPopup';
|
import NotificationPopup from '../NotificationPopup';
|
||||||
|
import './AppLayout.css';
|
||||||
|
|
||||||
interface MenuConfig {
|
interface MenuConfig {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -137,8 +140,9 @@ const AppLayout: React.FC = () => {
|
|||||||
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
|
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
|
||||||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||||||
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const [mobileExpandedMenus, setMobileExpandedMenus] = useState<Record<string, boolean>>({});
|
||||||
const [siteName, setSiteName] = useState(() => {
|
const [siteName, setSiteName] = useState(() => {
|
||||||
const cached = localStorage.getItem('siteInfo');
|
const cached = localStorage.getItem('siteInfo');
|
||||||
const name = cached ? JSON.parse(cached).siteName || '' : '';
|
const name = cached ? JSON.parse(cached).siteName || '' : '';
|
||||||
@@ -165,7 +169,7 @@ const AppLayout: React.FC = () => {
|
|||||||
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
|
const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null);
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
const [paymentMethod, setPaymentMethod] = useState<string>('alipay');
|
||||||
const [paying, setPaying] = useState(false);
|
const [paying, setPaying] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(180); // 默认180秒超时
|
const [countdown, setCountdown] = useState(180);
|
||||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const currentOrderNoRef = useRef<string | null>(null);
|
const currentOrderNoRef = useRef<string | null>(null);
|
||||||
@@ -175,11 +179,8 @@ const AppLayout: React.FC = () => {
|
|||||||
const [contactHovered, setContactHovered] = useState(false);
|
const [contactHovered, setContactHovered] = useState(false);
|
||||||
const [submittingContact, setSubmittingContact] = useState(false);
|
const [submittingContact, setSubmittingContact] = useState(false);
|
||||||
|
|
||||||
// LocalStorage keys
|
|
||||||
const PENDING_ORDER_KEY = 'pending_payment_order';
|
const PENDING_ORDER_KEY = 'pending_payment_order';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getSiteInfo().then(info => {
|
getSiteInfo().then(info => {
|
||||||
const name = info.siteName || '民众智创';
|
const name = info.siteName || '民众智创';
|
||||||
@@ -212,17 +213,14 @@ const AppLayout: React.FC = () => {
|
|||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 检查并恢复待处理的支付订单
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkPendingOrder = async () => {
|
const checkPendingOrder = async () => {
|
||||||
const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY);
|
const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY);
|
||||||
if (savedOrderStr) {
|
if (savedOrderStr) {
|
||||||
try {
|
try {
|
||||||
const savedOrder = JSON.parse(savedOrderStr);
|
const savedOrder = JSON.parse(savedOrderStr);
|
||||||
// 查询订单状态
|
|
||||||
const order = await getPaymentOrder(savedOrder.orderNo);
|
const order = await getPaymentOrder(savedOrder.orderNo);
|
||||||
if (order.status === 'pending') {
|
if (order.status === 'pending') {
|
||||||
// 订单仍然待支付,恢复弹窗
|
|
||||||
setCurrentPaymentInfo({
|
setCurrentPaymentInfo({
|
||||||
price: savedOrder.price,
|
price: savedOrder.price,
|
||||||
credits: savedOrder.credits,
|
credits: savedOrder.credits,
|
||||||
@@ -230,7 +228,6 @@ const AppLayout: React.FC = () => {
|
|||||||
method: savedOrder.method,
|
method: savedOrder.method,
|
||||||
});
|
});
|
||||||
currentOrderNoRef.current = savedOrder.orderNo;
|
currentOrderNoRef.current = savedOrder.orderNo;
|
||||||
// 计算剩余时间
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const createdAt = new Date(savedOrder.createdAt).getTime();
|
const createdAt = new Date(savedOrder.createdAt).getTime();
|
||||||
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
|
const timeoutSeconds = savedOrder.timeoutSeconds || 180;
|
||||||
@@ -241,20 +238,16 @@ const AppLayout: React.FC = () => {
|
|||||||
setQrCodeModalOpen(true);
|
setQrCodeModalOpen(true);
|
||||||
startPolling(savedOrder.orderNo, remainingSeconds);
|
startPolling(savedOrder.orderNo, remainingSeconds);
|
||||||
} else {
|
} else {
|
||||||
// 已超时,清除
|
|
||||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||||
}
|
}
|
||||||
} else if (order.status === 'paid') {
|
} else if (order.status === 'paid') {
|
||||||
// 已支付
|
|
||||||
message.success('支付成功!积分已到账');
|
message.success('支付成功!积分已到账');
|
||||||
useAuthStore.getState().refreshUser();
|
useAuthStore.getState().refreshUser();
|
||||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||||
} else {
|
} else {
|
||||||
// 订单已取消或其他状态,清除
|
|
||||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// 查询失败,清除
|
|
||||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +266,6 @@ const AppLayout: React.FC = () => {
|
|||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
getPaymentMethods().then(data => {
|
getPaymentMethods().then(data => {
|
||||||
setEnabledMethods(data);
|
setEnabledMethods(data);
|
||||||
// Auto-select the first enabled method
|
|
||||||
if (data.alipay) setPaymentMethod('alipay');
|
if (data.alipay) setPaymentMethod('alipay');
|
||||||
else if (data.wechat) setPaymentMethod('wechat');
|
else if (data.wechat) setPaymentMethod('wechat');
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
@@ -283,6 +275,47 @@ const AppLayout: React.FC = () => {
|
|||||||
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
|
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
|
||||||
const sidebarW = SIDEBAR_W;
|
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 [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
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 = [
|
const userMenuItems = [
|
||||||
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
|
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
|
||||||
{ key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
|
{ key: 'credits', icon: <WalletOutlined style={{ color: '#c9a96e' }} />, label: `积分: ${user?.credits ?? 0}`, disabled: true },
|
||||||
@@ -290,7 +323,6 @@ const AppLayout: React.FC = () => {
|
|||||||
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
|
{ key: 'myCredits', icon: <WalletOutlined />, label: '积分明细' },
|
||||||
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
|
{ key: 'orderRecords', icon: <FileTextOutlined />, label: '订单记录' },
|
||||||
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
|
{ key: 'messages', icon: <BellOutlined />, label: `消息中心${unreadCount > 0 ? `(${unreadCount})` : ''}` },
|
||||||
// { key: 'recharge', icon: <PlusCircleOutlined />, label: '充值积分' },
|
|
||||||
{ type: 'divider' as const },
|
{ type: 'divider' as const },
|
||||||
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
{ key: 'changePwd', icon: <LockOutlined />, label: '修改密码' },
|
||||||
{ type: 'divider' as const },
|
{ type: 'divider' as const },
|
||||||
@@ -311,7 +343,7 @@ const AppLayout: React.FC = () => {
|
|||||||
await pwdForm.validateFields();
|
await pwdForm.validateFields();
|
||||||
message.success('密码修改成功(演示)');
|
message.success('密码修改成功(演示)');
|
||||||
setPwdModalOpen(false); pwdForm.resetFields();
|
setPwdModalOpen(false); pwdForm.resetFields();
|
||||||
} catch { /* validation */ }
|
} catch { }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -363,7 +395,6 @@ const AppLayout: React.FC = () => {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
setCountdown(timeoutSeconds);
|
setCountdown(timeoutSeconds);
|
||||||
|
|
||||||
// 订单状态轮询(每2秒查询一次,只查询当前订单
|
|
||||||
const pollingTimer = setInterval(async () => {
|
const pollingTimer = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const order = await getPaymentOrder(orderNo);
|
const order = await getPaymentOrder(orderNo);
|
||||||
@@ -382,16 +413,13 @@ const AppLayout: React.FC = () => {
|
|||||||
localStorage.removeItem(PENDING_ORDER_KEY);
|
localStorage.removeItem(PENDING_ORDER_KEY);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore polling errors
|
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
pollingTimerRef.current = pollingTimer;
|
pollingTimerRef.current = pollingTimer;
|
||||||
|
|
||||||
// 倒计时
|
|
||||||
const countdownTimer = setInterval(() => {
|
const countdownTimer = setInterval(() => {
|
||||||
setCountdown(prev => {
|
setCountdown(prev => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
// 超时自动取消
|
|
||||||
stopPolling();
|
stopPolling();
|
||||||
if (currentOrderNoRef.current) {
|
if (currentOrderNoRef.current) {
|
||||||
cancelPaymentOrder(currentOrderNoRef.current).catch(() => { });
|
cancelPaymentOrder(currentOrderNoRef.current).catch(() => { });
|
||||||
@@ -410,9 +438,82 @@ const AppLayout: React.FC = () => {
|
|||||||
countdownTimerRef.current = countdownTimer;
|
countdownTimerRef.current = countdownTimer;
|
||||||
}, [stopPolling]);
|
}, [stopPolling]);
|
||||||
|
|
||||||
|
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
||||||
|
const isActive = item.path === selectedKey;
|
||||||
|
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
||||||
|
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
|
||||||
|
const menuType = item.menu_type ?? item.menuType;
|
||||||
|
const isGroup = menuType === 'group';
|
||||||
|
const isExpanded = collapsedGroups[item.id] !== true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
if (isGroup || hasChildren) {
|
||||||
|
setCollapsedGroups(prev => ({
|
||||||
|
...prev,
|
||||||
|
[item.id]: !prev[item.id]
|
||||||
|
}));
|
||||||
|
} else if (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: '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) {
|
||||||
|
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>
|
||||||
|
{(isGroup || hasChildren) && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: isGroup ? 12 : 14,
|
||||||
|
color: '#cbd5e1',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
|
}}>
|
||||||
|
<RightOutlined />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(isGroup || hasChildren) && isExpanded && (
|
||||||
|
<div style={{ overflow: 'hidden' }}>
|
||||||
|
{(childMap[item.id] || []).map(c => renderMenuItem(c, depth + 1))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
{/* Desktop Sidebar */}
|
|
||||||
<div className="desktop-sidebar" style={{
|
<div className="desktop-sidebar" style={{
|
||||||
width: sidebarW, position: 'fixed', left: 16, top: 16, bottom: 16, zIndex: 100,
|
width: sidebarW, position: 'fixed', left: 16, top: 16, bottom: 16, zIndex: 100,
|
||||||
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
|
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)',
|
||||||
@@ -423,7 +524,6 @@ const AppLayout: React.FC = () => {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||||
}}>
|
}}>
|
||||||
{/* Logo */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
height: 80, display: 'flex', alignItems: 'center',
|
height: 80, display: 'flex', alignItems: 'center',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
@@ -458,94 +558,10 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu */}
|
|
||||||
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
|
||||||
{/* {!collapsed && (
|
{topLevelItems.map(item => renderMenuItem(item))}
|
||||||
<div style={{ color: 'rgba(0,0,0,0.3)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
|
|
||||||
导航
|
|
||||||
</div>
|
|
||||||
)} */}
|
|
||||||
{(() => {
|
|
||||||
// Build child map for all menu items
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get top-level items (parent_id is null/undefined)
|
|
||||||
const topLevelItems = menuItems.filter(m => !(m.parent_id ?? m.parentId));
|
|
||||||
|
|
||||||
// Sort top-level items by sort_order
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
const items: React.ReactNode[] = [];
|
|
||||||
|
|
||||||
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
|
||||||
const isActive = item.path === selectedKey;
|
|
||||||
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
|
||||||
return (
|
|
||||||
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
|
|
||||||
display: 'flex', alignItems: 'center',
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
gap: 10,
|
|
||||||
padding: depth > 0 ? '6px 12px 6px 32px' : '6px 14px',
|
|
||||||
borderRadius: 12, margin: '1px 4px', cursor: 'pointer',
|
|
||||||
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
|
|
||||||
color: isActive ? '#4f46e5' : '#475569',
|
|
||||||
background: isActive ? 'linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.08) 100%)' : 'transparent',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize: depth > 0 ? 14 : 16,
|
|
||||||
flexShrink: 0,
|
|
||||||
color: isActive ? '#6366f1' : '#64748b',
|
|
||||||
}}>{menuIcon}</span>
|
|
||||||
<span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render top-level items and their children in order
|
|
||||||
topLevelItems.forEach(item => {
|
|
||||||
const menuType = item.menu_type ?? item.menuType;
|
|
||||||
|
|
||||||
if (menuType === 'group') {
|
|
||||||
// Render group with its children
|
|
||||||
const children = (childMap[item.id] || []).sort((a, b) => {
|
|
||||||
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
|
|
||||||
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
|
|
||||||
return orderA - orderB;
|
|
||||||
});
|
|
||||||
items.push(
|
|
||||||
<div key={item.id}>
|
|
||||||
<div style={{
|
|
||||||
color: '#94a3b8', fontSize: 12, fontWeight: 600,
|
|
||||||
padding: '10px 14px 4px', letterSpacing: 0.5, textTransform: 'uppercase',
|
|
||||||
}}>
|
|
||||||
{item.label}
|
|
||||||
</div>
|
|
||||||
{children.map(c => renderMenuItem(c, 1))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Render standalone page
|
|
||||||
items.push(renderMenuItem(item));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recharge button - opens modal */}
|
|
||||||
<div onClick={() => setRechargeModalOpen(true)} style={{
|
<div onClick={() => setRechargeModalOpen(true)} style={{
|
||||||
margin: '8px 12px',
|
margin: '8px 12px',
|
||||||
padding: '12px 20px',
|
padding: '12px 20px',
|
||||||
@@ -572,7 +588,6 @@ const AppLayout: React.FC = () => {
|
|||||||
<span>充值积分</span>
|
<span>充值积分</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User block at bottom-left */}
|
|
||||||
<div style={{ padding: '16px 16px', flexShrink: 0 }}>
|
<div style={{ padding: '16px 16px', flexShrink: 0 }}>
|
||||||
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -620,14 +635,12 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className="desktop-content" style={{
|
<div className="desktop-content" style={{
|
||||||
marginLeft: sidebarW + 28,
|
marginLeft: sidebarW + 28,
|
||||||
marginRight: 12,
|
marginRight: 12,
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
// height: '100%',
|
|
||||||
minHeight: 'calc(100vh - 32px)',
|
minHeight: 'calc(100vh - 32px)',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -647,31 +660,154 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Bottom Nav */}
|
<div className="mobile-header">
|
||||||
<div className="mobile-bottom-nav">
|
<div className="mobile-header-content">
|
||||||
{menuItems.filter((item) => (item.menu_type ?? item.menuType) !== 'group').map((item) => {
|
<div className="mobile-menu-btn" onClick={() => setMobileMenuOpen(true)}>
|
||||||
if (!item.path) return null;
|
<MenuOutlined style={{ fontSize: 20 }} />
|
||||||
const isActive = item.path === selectedKey;
|
</div>
|
||||||
return (
|
<div className="mobile-header-title">{siteName}</div>
|
||||||
<div key={item.id}
|
<div className="mobile-header-right">
|
||||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
<div className="mobile-credits-badge" onClick={() => setRechargeModalOpen(true)}>
|
||||||
onClick={() => navigate(item.path)}>
|
<WalletOutlined />
|
||||||
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
|
<span>{user?.credits || 0}</span>
|
||||||
<span>{item.label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
<div className="nav-item" onClick={handleMobileRecharge}>
|
|
||||||
<span className="nav-icon"><PlusCircleOutlined /></span>
|
|
||||||
<span>充值</span>
|
|
||||||
</div>
|
|
||||||
<div className="nav-item" onClick={handleLogout}>
|
|
||||||
<span className="nav-icon"><LogoutOutlined /></span>
|
|
||||||
<span>退出</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Change Password Modal */}
|
<div className="mobile-content">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||||
|
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 4px 12px rgba(99, 102, 241, 0.35)',
|
||||||
|
}}>
|
||||||
|
{siteLogo ? (
|
||||||
|
<img src={siteLogo} alt="logo" style={{ width: 24, height: 24, objectFit: 'contain' }} />
|
||||||
|
) : (
|
||||||
|
<ThunderboltOutlined style={{ fontSize: 18, color: '#ffffff' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 16, color: '#1e293b' }}>{siteName}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
placement="left"
|
||||||
|
onClose={() => setMobileMenuOpen(false)}
|
||||||
|
open={mobileMenuOpen}
|
||||||
|
width={280}
|
||||||
|
closable={true}
|
||||||
|
className="mobile-menu-drawer"
|
||||||
|
styles={{
|
||||||
|
header: { borderBottom: '1px solid #f1f5f9', padding: '16px 20px' },
|
||||||
|
body: { padding: '12px 8px', display: 'flex', flexDirection: 'column' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', paddingBottom: 12 }}>
|
||||||
|
{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 mobile-recharge-item"
|
||||||
|
onClick={() => {
|
||||||
|
setRechargeModalOpen(true);
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mobile-menu-icon" style={{ color: '#fff' }}>
|
||||||
|
<PlusOutlined />
|
||||||
|
</span>
|
||||||
|
<span className="mobile-menu-label" style={{ color: '#fff', fontWeight: 600 }}>充值积分</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mobile-menu-item"
|
||||||
|
onClick={() => {
|
||||||
|
handleLogout();
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="mobile-menu-icon" style={{ color: '#ef4444' }}>
|
||||||
|
<LogoutOutlined />
|
||||||
|
</span>
|
||||||
|
<span className="mobile-menu-label" style={{ color: '#ef4444' }}>退出登录</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<Modal title={<Space><LockOutlined />修改密码</Space>} open={pwdModalOpen}
|
<Modal title={<Space><LockOutlined />修改密码</Space>} open={pwdModalOpen}
|
||||||
onOk={handleChangePwd} onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); }}
|
onOk={handleChangePwd} onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); }}
|
||||||
okText="确认修改" cancelText="取消" width={440}>
|
okText="确认修改" cancelText="取消" width={440}>
|
||||||
@@ -696,10 +832,11 @@ const AppLayout: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Recharge Modal */}
|
|
||||||
<Modal title={<Space><GiftOutlined />积分充值</Space>} open={rechargeModalOpen}
|
<Modal title={<Space><GiftOutlined />积分充值</Space>} open={rechargeModalOpen}
|
||||||
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||||
footer={null} width={680}>
|
footer={null} width={680}
|
||||||
|
className="recharge-modal"
|
||||||
|
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
<WalletOutlined style={{ color: '#6366f1' }} />
|
<WalletOutlined style={{ color: '#6366f1' }} />
|
||||||
@@ -756,7 +893,6 @@ const AppLayout: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment method selection */}
|
|
||||||
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
{(!enabledMethods.alipay && !enabledMethods.wechat) ? (
|
||||||
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
<div style={{ marginTop: 20, marginBottom: 8, padding: 16, background: '#fef2f2', borderRadius: 12, border: '1px solid #fecaca' }}>
|
||||||
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
<Typography.Text style={{ color: '#dc2626', fontSize: 13 }}>
|
||||||
@@ -802,10 +938,8 @@ const AppLayout: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setPaying(true);
|
setPaying(true);
|
||||||
const order = await createRechargeOrder(plan.id, paymentMethod);
|
const order = await createRechargeOrder(plan.id, paymentMethod);
|
||||||
// 检查是否有二维码信息,支持支付宝和微信支付
|
|
||||||
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
const qrCode = order.qrUrl || order.codeUrl || order.qr_code || order.code_url;
|
||||||
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
if ((order.paymentMethod === 'alipay' || order.paymentMethod === 'wechat') && qrCode) {
|
||||||
// Alipay or WeChat Pay: show the QR code
|
|
||||||
const paymentInfo = {
|
const paymentInfo = {
|
||||||
price: plan.price,
|
price: plan.price,
|
||||||
credits: totalCredits,
|
credits: totalCredits,
|
||||||
@@ -817,7 +951,6 @@ const AppLayout: React.FC = () => {
|
|||||||
setQrCodeModalOpen(true);
|
setQrCodeModalOpen(true);
|
||||||
currentOrderNoRef.current = order.orderNo;
|
currentOrderNoRef.current = order.orderNo;
|
||||||
|
|
||||||
// 保存到 localStorage
|
|
||||||
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({
|
||||||
orderNo: order.orderNo,
|
orderNo: order.orderNo,
|
||||||
price: plan.price,
|
price: plan.price,
|
||||||
@@ -828,10 +961,8 @@ const AppLayout: React.FC = () => {
|
|||||||
timeoutSeconds: 180,
|
timeoutSeconds: 180,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Start polling for payment status
|
|
||||||
startPolling(order.orderNo);
|
startPolling(order.orderNo);
|
||||||
} else {
|
} else {
|
||||||
// Mock mode (auto-completes, no QR needed)
|
|
||||||
message.success('充值成功!积分已到账');
|
message.success('充值成功!积分已到账');
|
||||||
useAuthStore.getState().refreshUser();
|
useAuthStore.getState().refreshUser();
|
||||||
setRechargeModalOpen(false);
|
setRechargeModalOpen(false);
|
||||||
@@ -854,12 +985,10 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* QR Code Payment Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
open={qrCodeModalOpen}
|
open={qrCodeModalOpen}
|
||||||
onCancel={async () => {
|
onCancel={async () => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
// Mark order as cancelled if it's still pending
|
|
||||||
if (currentOrderNoRef.current) {
|
if (currentOrderNoRef.current) {
|
||||||
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
|
try { await cancelPaymentOrder(currentOrderNoRef.current); } catch { }
|
||||||
currentOrderNoRef.current = null;
|
currentOrderNoRef.current = null;
|
||||||
@@ -876,7 +1005,6 @@ const AppLayout: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ padding: '24px' }}>
|
<div style={{ padding: '24px' }}>
|
||||||
{/* Header */}
|
|
||||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 48, height: 48,
|
width: 48, height: 48,
|
||||||
@@ -901,7 +1029,6 @@ const AppLayout: React.FC = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QR Code */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
@@ -945,7 +1072,6 @@ const AppLayout: React.FC = () => {
|
|||||||
}}>
|
}}>
|
||||||
购买 {currentPaymentInfo?.credits || 0} 积分
|
购买 {currentPaymentInfo?.credits || 0} 积分
|
||||||
</div>
|
</div>
|
||||||
{/* 倒计时显示 */}
|
|
||||||
<div style={{
|
<div style={{
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
@@ -965,7 +1091,6 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tips */}
|
|
||||||
<div style={{ marginTop: 20, padding: 16, background: '#fef3c7', borderRadius: 12 }}>
|
<div style={{ marginTop: 20, padding: 16, background: '#fef3c7', borderRadius: 12 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||||
<div style={{ fontSize: 16, marginTop: -2 }}>💡</div>
|
<div style={{ fontSize: 16, marginTop: -2 }}>💡</div>
|
||||||
@@ -979,7 +1104,6 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Buttons */}
|
|
||||||
<div style={{ marginTop: 20 }}>
|
<div style={{ marginTop: 20 }}>
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
@@ -1005,73 +1129,36 @@ const AppLayout: React.FC = () => {
|
|||||||
|
|
||||||
<NotificationPopup />
|
<NotificationPopup />
|
||||||
|
|
||||||
{/* Contact Button */}
|
<div className="contact-button-wrapper">
|
||||||
<div style={{
|
|
||||||
position: 'fixed',
|
|
||||||
right: 24,
|
|
||||||
bottom: 24,
|
|
||||||
zIndex: 1000,
|
|
||||||
}}>
|
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
<div
|
<div
|
||||||
|
className="contact-tooltip"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
|
||||||
right: 64,
|
|
||||||
bottom: 8,
|
|
||||||
padding: '8px 16px',
|
|
||||||
background: '#1e293b',
|
|
||||||
color: '#ffffff',
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 13,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
opacity: contactHovered ? 1 : 0,
|
opacity: contactHovered ? 1 : 0,
|
||||||
transition: 'opacity 0.2s ease',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
联系我们
|
联系我们
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setContactModalOpen(true)}
|
onClick={() => setContactModalOpen(true)}
|
||||||
style={{
|
className="contact-button"
|
||||||
width: 40,
|
onMouseEnter={() => setContactHovered(true)}
|
||||||
height: 40,
|
onMouseLeave={() => setContactHovered(false)}
|
||||||
borderRadius: '50%',
|
|
||||||
border: 'none',
|
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
||||||
color: '#ffffff',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
boxShadow: '0 4px 20px rgba(99, 102, 241, 0.4)',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1.05)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 6px 24px rgba(99, 102, 241, 0.5)';
|
|
||||||
setContactHovered(true);
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.transform = 'scale(1)';
|
|
||||||
e.currentTarget.style.boxShadow = '0 4px 20px rgba(99, 102, 241, 0.4)';
|
|
||||||
setContactHovered(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MessageOutlined style={{ fontSize: 20 }} />
|
<MessageOutlined style={{ fontSize: 20 }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
title={<Space><MessageOutlined />联系我们</Space>}
|
title={<Space><MessageOutlined />联系我们</Space>}
|
||||||
open={contactModalOpen}
|
open={contactModalOpen}
|
||||||
onCancel={() => { setContactModalOpen(false); contactForm.resetFields(); }}
|
onCancel={() => { setContactModalOpen(false); contactForm.resetFields(); }}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={480}
|
width={480}
|
||||||
|
className="contact-modal"
|
||||||
>
|
>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<Form form={contactForm} layout="vertical">
|
<Form form={contactForm} layout="vertical">
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-image: url(/backimage.png);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.login-page {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-decoration {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(40px);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-decoration-1 {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%);
|
||||||
|
top: -150px;
|
||||||
|
right: -100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-decoration-2 {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%);
|
||||||
|
bottom: -100px;
|
||||||
|
left: -80px;
|
||||||
|
filter: blur(50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-left-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.login-left-section {
|
||||||
|
padding: 0 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-left-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-logo-row {
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo-img {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 14px;
|
||||||
|
objectFit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo-placeholder {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8px 24px rgba(99,102,241,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-site-name {
|
||||||
|
color: #1e293b;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-site-name {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-desc {
|
||||||
|
color: #64748b !important;
|
||||||
|
font-size: 17px !important;
|
||||||
|
max-width: 480px;
|
||||||
|
line-height: 1.8 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.login-desc {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-features-list {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.login-features-list {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-feature-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 18px 22px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255,255,255,0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-feature-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-feature-title {
|
||||||
|
color: #1e293b !important;
|
||||||
|
font-size: 15px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
display: block !important;
|
||||||
|
margin-bottom: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-feature-desc {
|
||||||
|
color: #64748b !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-right-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 16px 16px 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.login-right-section {
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
border-radius: 20px !important;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.08) !important;
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-card {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
}
|
||||||
|
.login-card .ant-card-body {
|
||||||
|
padding: 24px 20px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card-title {
|
||||||
|
text-align: center !important;
|
||||||
|
margin-bottom: 6px !important;
|
||||||
|
color: #1e293b !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card-subtitle {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tab {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #64748b;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-tab-active {
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #6366f1 !important;
|
||||||
|
background: #fff !important;
|
||||||
|
border: 1px solid rgba(99,102,241,0.2) !important;
|
||||||
|
box-shadow: 0 2px 8px rgba(99,102,241,0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit-btn {
|
||||||
|
height: 48px !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 8px 24px rgba(99,102,241,0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-code-btn {
|
||||||
|
height: 48px !important;
|
||||||
|
border-radius: 0 10px 10px 0 !important;
|
||||||
|
border: 1.5px solid #e2e8f0 !important;
|
||||||
|
border-left: none !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
min-width: 100px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-agreement {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-agreement-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
color: #6366f1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.login-footer {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-switch-btn {
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: #6366f1 !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-left-section {
|
||||||
|
padding: 24px 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-right-section {
|
||||||
|
padding: 8px 12px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shiny-text-container {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/useAuthStore';
|
import { useAuthStore } from '../store/useAuthStore';
|
||||||
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
|
import { sendSms,phonelogin, getSiteInfo, register } from '../api';
|
||||||
|
import './LoginPage.css';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api';
|
||||||
|
|
||||||
@@ -73,7 +74,6 @@ const LoginPage: React.FC = () => {
|
|||||||
if (!checkAgreed()) return;
|
if (!checkAgreed()) return;
|
||||||
try {
|
try {
|
||||||
const values = await pwdForm.validateFields();
|
const values = await pwdForm.validateFields();
|
||||||
// setLoading(true);
|
|
||||||
await login(values.phone, values.password, undefined, values.rememberMe);
|
await login(values.phone, values.password, undefined, values.rememberMe);
|
||||||
message.success('登录成功,欢迎回来');
|
message.success('登录成功,欢迎回来');
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
@@ -82,7 +82,6 @@ const LoginPage: React.FC = () => {
|
|||||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||||
message.error(errorMsg);
|
message.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
// setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,7 +130,6 @@ const LoginPage: React.FC = () => {
|
|||||||
if (c <= 1) {
|
if (c <= 1) {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
setShowSliderVerify(false);
|
setShowSliderVerify(false);
|
||||||
// 倒计时结束后,设置重新发送状态
|
|
||||||
if (isReg) {
|
if (isReg) {
|
||||||
setShowResend(true);
|
setShowResend(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -144,7 +142,6 @@ const LoginPage: React.FC = () => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 登录模式倒计时结束后重置验证状态
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (countdown === 0 && mode === 'phone') {
|
if (countdown === 0 && mode === 'phone') {
|
||||||
setLoginSliderVerified(false);
|
setLoginSliderVerified(false);
|
||||||
@@ -158,14 +155,12 @@ const LoginPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册时需要滑动验证
|
|
||||||
if (isReg) {
|
if (isReg) {
|
||||||
setShowSliderVerify(true);
|
setShowSliderVerify(true);
|
||||||
setSliderVerified(false);
|
setSliderVerified(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录时也需要滑动验证
|
|
||||||
if (!loginSliderVerified) {
|
if (!loginSliderVerified) {
|
||||||
setShowSliderVerify(true);
|
setShowSliderVerify(true);
|
||||||
setLoginSliderVerified(false);
|
setLoginSliderVerified(false);
|
||||||
@@ -185,7 +180,6 @@ const LoginPage: React.FC = () => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||||
message.error(errorMsg);
|
message.error(errorMsg);
|
||||||
// 发送失败,重置验证状态、倒计时和滑块组件
|
|
||||||
if (mode === 'register') {
|
if (mode === 'register') {
|
||||||
setSliderVerified(false);
|
setSliderVerified(false);
|
||||||
setRegCountdown(0);
|
setRegCountdown(0);
|
||||||
@@ -193,18 +187,13 @@ const LoginPage: React.FC = () => {
|
|||||||
setLoginSliderVerified(false);
|
setLoginSliderVerified(false);
|
||||||
setCountdown(0);
|
setCountdown(0);
|
||||||
}
|
}
|
||||||
// 通过更新 key 强制刷新滑块组件
|
|
||||||
setSliderKey(prev => prev + 1);
|
setSliderKey(prev => prev + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 滑动验证成功后的回调
|
|
||||||
const handleSliderSuccess = async (isResend = false) => {
|
const handleSliderSuccess = async (isResend = false) => {
|
||||||
// 根据当前模式获取手机号
|
|
||||||
const phone = mode === 'register' ? regForm.getFieldValue('phone') : phoneForm.getFieldValue('phone');
|
const phone = mode === 'register' ? regForm.getFieldValue('phone') : phoneForm.getFieldValue('phone');
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
||||||
let mode2 = '';
|
let mode2 = '';
|
||||||
if (mode === 'register') {
|
if (mode === 'register') {
|
||||||
mode2 = 'register';
|
mode2 = 'register';
|
||||||
@@ -213,7 +202,6 @@ const LoginPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await sendSms(phone,mode2);
|
await sendSms(phone,mode2);
|
||||||
// 设置对应的验证状态
|
|
||||||
if (mode === 'register') {
|
if (mode === 'register') {
|
||||||
setSliderVerified(true);
|
setSliderVerified(true);
|
||||||
startCountdown(setRegCountdown, true);
|
startCountdown(setRegCountdown, true);
|
||||||
@@ -225,7 +213,6 @@ const LoginPage: React.FC = () => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
const errorMsg = error?.response?.data?.detail || error?.response?.data?.message || error?.message || '登录失败';
|
||||||
message.error(errorMsg);
|
message.error(errorMsg);
|
||||||
// 发送失败,重置验证状态、倒计时和滑块组件
|
|
||||||
if (mode === 'register') {
|
if (mode === 'register') {
|
||||||
setSliderVerified(false);
|
setSliderVerified(false);
|
||||||
setRegCountdown(0);
|
setRegCountdown(0);
|
||||||
@@ -235,7 +222,6 @@ const LoginPage: React.FC = () => {
|
|||||||
setCountdown(0);
|
setCountdown(0);
|
||||||
setLoginShowResend(true);
|
setLoginShowResend(true);
|
||||||
}
|
}
|
||||||
// 通过更新 key 强制刷新滑块组件
|
|
||||||
setSliderKey(prev => prev + 1);
|
setSliderKey(prev => prev + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -250,9 +236,7 @@ const LoginPage: React.FC = () => {
|
|||||||
setShowSliderVerify(false);
|
setShowSliderVerify(false);
|
||||||
setShowResend(false);
|
setShowResend(false);
|
||||||
setLoginShowResend(false);
|
setLoginShowResend(false);
|
||||||
// 刷新滑动验证组件
|
|
||||||
setSliderKey(prev => prev + 1);
|
setSliderKey(prev => prev + 1);
|
||||||
// 清空当前模式相关的表单
|
|
||||||
if (t === 'password') {
|
if (t === 'password') {
|
||||||
pwdForm.resetFields();
|
pwdForm.resetFields();
|
||||||
} else {
|
} else {
|
||||||
@@ -280,93 +264,45 @@ const LoginPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div className="login-page">
|
||||||
minHeight: '100vh',
|
<div className="login-bg-overlay" />
|
||||||
display: 'flex',
|
<div className="login-decoration login-decoration-1" />
|
||||||
backgroundImage: `url(/backimage.png)`,
|
<div className="login-decoration login-decoration-2" />
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
backgroundAttachment: 'fixed',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}>
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
background: 'linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%)',
|
|
||||||
}} />
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
width: 500, height: 500, borderRadius: '50%',
|
|
||||||
background: 'radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%)',
|
|
||||||
top: -150, right: -100, filter: 'blur(40px)',
|
|
||||||
}} />
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
width: 400, height: 400, borderRadius: '50%',
|
|
||||||
background: 'radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%)',
|
|
||||||
bottom: -100, left: -80, filter: 'blur(50px)',
|
|
||||||
}} />
|
|
||||||
|
|
||||||
{/* Left side - features */}
|
<div className="login-left-section">
|
||||||
<div style={{
|
<Space direction="vertical" size={36} className="login-left-content">
|
||||||
flex: 1, display: 'flex', flexDirection: 'column',
|
|
||||||
justifyContent: 'center', padding: '0 80px', zIndex: 1,
|
|
||||||
}}>
|
|
||||||
<Space direction="vertical" size={36}>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
<div className="login-logo-row">
|
||||||
{siteLogo ? (
|
{siteLogo ? (
|
||||||
<img src={siteLogo} alt="logo" style={{ width: 52, height: 52, borderRadius: 14, objectFit: 'contain' }} />
|
<img src={siteLogo} alt="logo" className="login-logo-img" />
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div className="login-logo-placeholder">
|
||||||
width: 52, height: 52, borderRadius: 14,
|
|
||||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
|
||||||
}}>
|
|
||||||
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
|
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span style={{ color: '#1e293b', fontSize: 28, fontWeight: 800, letterSpacing: -0.5 }}>
|
<span className="login-site-name">{siteName}</span>
|
||||||
{siteName}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="shiny-text-container">
|
<div className="shiny-text-container">
|
||||||
<span className="shiny-text">AI赋能创意,素材触手可及</span>
|
<span className="shiny-text">AI赋能创意,素材触手可及</span>
|
||||||
</div>
|
</div>
|
||||||
<Typography.Paragraph style={{ color: '#64748b', fontSize: 17, maxWidth: 480, lineHeight: 1.8 }}>
|
<Typography.Paragraph className="login-desc">
|
||||||
专业的 AI 素材生成平台,通过智能提示词优化,<br />让您的创意快速转化为精美视频、图片
|
专业的 AI 素材生成平台,通过智能提示词优化,<br />让您的创意快速转化为精美视频、图片
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
<div className="login-features-list">
|
||||||
{features.map((f, i) => (
|
{features.map((f, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="electric-border-card"
|
className="electric-border-card login-feature-card"
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
display: 'flex', gap: 16, alignItems: 'flex-start',
|
|
||||||
padding: '18px 22px', borderRadius: 14,
|
|
||||||
background: 'rgba(255,255,255,0.85)',
|
|
||||||
backdropFilter: 'blur(12px)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="electric-border" />
|
<div className="electric-border" />
|
||||||
<div className="electric-border-inner" />
|
<div className="electric-border-inner" />
|
||||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||||
<div style={{
|
<div className="login-feature-icon">{f.icon}</div>
|
||||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
|
||||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15))',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
color: '#6366f1', fontSize: 20,
|
|
||||||
}}>{f.icon}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||||
<Typography.Text style={{ color: '#1e293b', fontSize: 15, fontWeight: 600, display: 'block', marginBottom: 4 }}>{f.title}</Typography.Text>
|
<Typography.Text className="login-feature-title">{f.title}</Typography.Text>
|
||||||
<Typography.Text style={{ color: '#64748b', fontSize: 13, lineHeight: 1.6 }}>{f.desc}</Typography.Text>
|
<Typography.Text className="login-feature-desc">{f.desc}</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -374,48 +310,26 @@ const LoginPage: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - login/register form */}
|
<div className="login-right-section">
|
||||||
<div style={{
|
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
|
||||||
flex: 1, display: 'flex', alignItems: 'center',
|
<Typography.Title level={3} className="login-card-title">
|
||||||
justifyContent: 'center', zIndex: 1, padding: '40px 24px',
|
|
||||||
}}>
|
|
||||||
<Card style={{
|
|
||||||
width: 440, maxWidth: '100%', borderRadius: 20,
|
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
|
|
||||||
border: '1px solid #e2e8f0', background: '#fff',
|
|
||||||
}} styles={{ body: { padding: '36px 28px' } }}>
|
|
||||||
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 6, color: '#1e293b', fontWeight: 700 }}>
|
|
||||||
{mode === 'register' ? '创建账号' : '欢迎回来'}
|
{mode === 'register' ? '创建账号' : '欢迎回来'}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Typography.Text style={{ display: 'block', textAlign: 'center', marginBottom: 28, color: '#94a3b8', fontSize: 14 }}>
|
<Typography.Text className="login-card-subtitle">
|
||||||
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
|
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
{/* Tab switcher - only for login modes */}
|
|
||||||
{mode !== 'register' && (
|
{mode !== 'register' && (
|
||||||
<div style={{
|
<div className="login-tabs">
|
||||||
display: 'flex', gap: 0, marginBottom: 24,
|
|
||||||
background: '#f1f5f9', borderRadius: 10, padding: 4,
|
|
||||||
border: '1px solid #e2e8f0',
|
|
||||||
}}>
|
|
||||||
{(['password', 'phone'] as const).map((t) => (
|
{(['password', 'phone'] as const).map((t) => (
|
||||||
<div key={t} onClick={() => switchTab(t)} style={{
|
<div key={t} onClick={() => switchTab(t)}
|
||||||
flex: 1, textAlign: 'center', padding: '10px 0',
|
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
|
||||||
borderRadius: 8, cursor: 'pointer', fontSize: 14,
|
|
||||||
fontWeight: tab === t ? 600 : 400,
|
|
||||||
color: tab === t ? '#6366f1' : '#64748b',
|
|
||||||
background: tab === t ? '#fff' : 'transparent',
|
|
||||||
border: tab === t ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
|
|
||||||
boxShadow: tab === t ? '0 2px 8px rgba(99,102,241,0.1)' : 'none',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
}}>
|
|
||||||
{t === 'password' ? '密码登录' : '验证码登录'}
|
{t === 'password' ? '密码登录' : '验证码登录'}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Password Login */}
|
|
||||||
{mode === 'password' && (
|
{mode === 'password' && (
|
||||||
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
|
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
|
||||||
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||||
@@ -428,16 +342,11 @@ const LoginPage: React.FC = () => {
|
|||||||
<Checkbox>记住我的登录状态</Checkbox>
|
<Checkbox>记住我的登录状态</Checkbox>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item style={{ marginBottom: 12 }}>
|
<Form.Item style={{ marginBottom: 12 }}>
|
||||||
<Button type="primary" htmlType="submit" loading={loading} block style={{
|
<Button type="primary" htmlType="submit" loading={loading} block className="login-submit-btn">登 录</Button>
|
||||||
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
|
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
||||||
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
|
||||||
}}>登 录</Button>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Phone Login */}
|
|
||||||
{mode === 'phone' && (
|
{mode === 'phone' && (
|
||||||
<Form form={phoneForm} size="large" layout="vertical">
|
<Form form={phoneForm} size="large" layout="vertical">
|
||||||
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||||
@@ -454,27 +363,18 @@ const LoginPage: React.FC = () => {
|
|||||||
<Button disabled={countdown > 0}
|
<Button disabled={countdown > 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (loginShowResend) {
|
if (loginShowResend) {
|
||||||
// 重新发送时,只刷新滑块,不启动倒计时
|
|
||||||
setLoginSliderVerified(false);
|
setLoginSliderVerified(false);
|
||||||
setShowSliderVerify(true);
|
setShowSliderVerify(true);
|
||||||
setSliderKey(prev => prev + 1);
|
setSliderKey(prev => prev + 1);
|
||||||
} else {
|
} else {
|
||||||
// 首次点击,调用发送验证码,等待滑块验证
|
|
||||||
handleSendCode(phoneForm.getFieldValue('phone'));
|
handleSendCode(phoneForm.getFieldValue('phone'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
className="login-code-btn">
|
||||||
height: 48, borderRadius: '0 10px 10px 0',
|
|
||||||
background: countdown > 0 ? '#f1f5f9' : (loginSliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)'),
|
|
||||||
border: '1.5px solid #e2e8f0', borderLeft: 'none',
|
|
||||||
color: countdown > 0 ? '#94a3b8' : '#6366f1',
|
|
||||||
fontWeight: 600, minWidth: 100,
|
|
||||||
}}>
|
|
||||||
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
|
{countdown > 0 ? `${countdown}s` : (loginShowResend ? '重新发送' : '获取验证码')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{/* 滑动验证 - 获取验证码后显示 */}
|
|
||||||
{showSliderVerify && (
|
{showSliderVerify && (
|
||||||
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
|
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
|
||||||
<SliderVerify
|
<SliderVerify
|
||||||
@@ -485,16 +385,11 @@ const LoginPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 12 }}>
|
<Form.Item style={{ marginBottom: 12 }}>
|
||||||
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block style={{
|
<Button type="primary" onClick={handlePhoneLogin} loading={loading} block className="login-submit-btn">登 录</Button>
|
||||||
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
|
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
||||||
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
|
||||||
}}>登 录</Button>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Register */}
|
|
||||||
{mode === 'register' && (
|
{mode === 'register' && (
|
||||||
<Form form={regForm} size="large" layout="vertical">
|
<Form form={regForm} size="large" layout="vertical">
|
||||||
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
<Form.Item name="phone" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||||
@@ -511,27 +406,18 @@ const LoginPage: React.FC = () => {
|
|||||||
<Button disabled={regCountdown > 0}
|
<Button disabled={regCountdown > 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showResend) {
|
if (showResend) {
|
||||||
// 重新发送时,只刷新滑块,不启动倒计时
|
|
||||||
setSliderVerified(false);
|
setSliderVerified(false);
|
||||||
setShowSliderVerify(true);
|
setShowSliderVerify(true);
|
||||||
setSliderKey(prev => prev + 1);
|
setSliderKey(prev => prev + 1);
|
||||||
} else {
|
} else {
|
||||||
// 首次点击,调用发送验证码,等待滑块验证
|
|
||||||
handleSendCode(regForm.getFieldValue('phone'), true);
|
handleSendCode(regForm.getFieldValue('phone'), true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
className="login-code-btn">
|
||||||
height: 48, borderRadius: '0 10px 10px 0',
|
|
||||||
background: regCountdown > 0 ? '#f1f5f9' : (sliderVerified ? '#f1f5f9' : 'rgba(99,102,241,0.1)'),
|
|
||||||
border: '1.5px solid #e2e8f0', borderLeft: 'none',
|
|
||||||
color: regCountdown > 0 ? '#94a3b8' : '#6366f1',
|
|
||||||
fontWeight: 600, minWidth: 100,
|
|
||||||
}}>
|
|
||||||
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
|
{regCountdown > 0 ? `${regCountdown}s` : (showResend ? '重新发送' : '获取验证码')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{/* 滑动验证 - 获取验证码后显示 */}
|
|
||||||
{showSliderVerify && (
|
{showSliderVerify && (
|
||||||
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
|
<Form.Item style={{ marginBottom: 12 }} key={sliderKey}>
|
||||||
<SliderVerify
|
<SliderVerify
|
||||||
@@ -544,41 +430,33 @@ const LoginPage: React.FC = () => {
|
|||||||
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
|
<Input.Password prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} placeholder="请设置密码(至少6位)" style={inputStyle} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Form.Item style={{ marginBottom: 12 }}>
|
<Form.Item style={{ marginBottom: 12 }}>
|
||||||
<Button type="primary" onClick={handleRegister} loading={loading} block style={{
|
<Button type="primary" onClick={handleRegister} loading={loading} block className="login-submit-btn">注 册</Button>
|
||||||
height: 48, borderRadius: 10, fontSize: 16, fontWeight: 600,
|
|
||||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
|
||||||
border: 'none', boxShadow: '0 8px 24px rgba(99,102,241,0.25)',
|
|
||||||
}}>注 册</Button>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Agreement checkbox */}
|
<div className="login-agreement">
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
|
<Checkbox checked={agreed} onChange={e => setAgreed(e.target.checked)}>
|
||||||
<span style={{ fontSize: 13, color: '#64748b' }}>
|
<span className="login-agreement-text">
|
||||||
我已阅读并同意
|
我已阅读并同意
|
||||||
<span
|
<span
|
||||||
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
|
onClick={e => { e.stopPropagation(); openPdf(agreementUrl); }}
|
||||||
style={{ color: '#6366f1', cursor: 'pointer' }}
|
className="login-link"
|
||||||
>《用户协议》</span>
|
>《用户协议》</span>
|
||||||
和
|
和
|
||||||
<span
|
<span
|
||||||
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
|
onClick={e => { e.stopPropagation(); openPdf(policyUrl); }}
|
||||||
style={{ color: '#6366f1', cursor: 'pointer' }}
|
className="login-link"
|
||||||
>《隐私政策》</span>
|
>《隐私政策》</span>
|
||||||
</span>
|
</span>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom left: switch between login and register */}
|
<div className="login-footer">
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
||||||
{mode === 'register' ? (
|
{mode === 'register' ? (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
|
className="login-switch-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode('password');
|
setMode('password');
|
||||||
setTab('password');
|
setTab('password');
|
||||||
@@ -594,7 +472,7 @@ const LoginPage: React.FC = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
style={{ fontSize: 13, color: '#6366f1', cursor: 'pointer' }}
|
className="login-switch-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMode('register');
|
setMode('register');
|
||||||
setShowResend(false);
|
setShowResend(false);
|
||||||
@@ -614,7 +492,6 @@ const LoginPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 滑动验证组件
|
|
||||||
const SliderVerify: React.FC<{
|
const SliderVerify: React.FC<{
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
@@ -623,13 +500,12 @@ const SliderVerify: React.FC<{
|
|||||||
const sliderRef = React.useRef<HTMLDivElement>(null);
|
const sliderRef = React.useRef<HTMLDivElement>(null);
|
||||||
const trackRef = React.useRef<HTMLDivElement>(null);
|
const trackRef = React.useRef<HTMLDivElement>(null);
|
||||||
const positionRef = React.useRef(0);
|
const positionRef = React.useRef(0);
|
||||||
const successRef = React.useRef(false); // 防止重复触发成功回调
|
const successRef = React.useRef(false);
|
||||||
|
|
||||||
const [containerWidth, setContainerWidth] = React.useState(360); // 容器宽度,自适应
|
const [containerWidth, setContainerWidth] = React.useState(360);
|
||||||
const sliderWidth = 50; // 滑块宽度
|
const sliderWidth = 50;
|
||||||
const maxPosition = containerWidth - sliderWidth;
|
const maxPosition = containerWidth - sliderWidth;
|
||||||
|
|
||||||
// 监听容器尺寸变化,使其自适应父容器宽度
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const updateWidth = () => {
|
const updateWidth = () => {
|
||||||
if (containerRef.current) {
|
if (containerRef.current) {
|
||||||
@@ -656,13 +532,11 @@ const SliderVerify: React.FC<{
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updatePosition = (x: number) => {
|
const updatePosition = (x: number) => {
|
||||||
// 如果已经成功,不再处理
|
|
||||||
if (successRef.current || isVerified) return;
|
if (successRef.current || isVerified) return;
|
||||||
|
|
||||||
const newPosition = Math.max(0, Math.min(x, maxPosition));
|
const newPosition = Math.max(0, Math.min(x, maxPosition));
|
||||||
positionRef.current = newPosition;
|
positionRef.current = newPosition;
|
||||||
|
|
||||||
// 直接操作DOM,避免React状态更新的开销
|
|
||||||
if (sliderRef.current) {
|
if (sliderRef.current) {
|
||||||
sliderRef.current.style.left = `${newPosition}px`;
|
sliderRef.current.style.left = `${newPosition}px`;
|
||||||
}
|
}
|
||||||
@@ -670,12 +544,9 @@ const SliderVerify: React.FC<{
|
|||||||
trackRef.current.style.width = `${newPosition + sliderWidth}px`;
|
trackRef.current.style.width = `${newPosition + sliderWidth}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证成功
|
|
||||||
if (newPosition >= maxPosition - 5) {
|
if (newPosition >= maxPosition - 5) {
|
||||||
// 设置成功标志,防止重复触发
|
|
||||||
successRef.current = true;
|
successRef.current = true;
|
||||||
|
|
||||||
// 设置验证成功状态
|
|
||||||
if (sliderRef.current) {
|
if (sliderRef.current) {
|
||||||
sliderRef.current.style.background = '#22c55e';
|
sliderRef.current.style.background = '#22c55e';
|
||||||
sliderRef.current.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
|
sliderRef.current.innerHTML = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>`;
|
||||||
@@ -685,7 +556,6 @@ const SliderVerify: React.FC<{
|
|||||||
trackRef.current.style.background = '#dcfce7';
|
trackRef.current.style.background = '#dcfce7';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用成功回调
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -708,7 +578,6 @@ const SliderVerify: React.FC<{
|
|||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener('mousemove', handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
|
||||||
// 如果没有滑到终点,重置位置
|
|
||||||
if (!successRef.current && positionRef.current < maxPosition - 5) {
|
if (!successRef.current && positionRef.current < maxPosition - 5) {
|
||||||
positionRef.current = 0;
|
positionRef.current = 0;
|
||||||
if (sliderRef.current) {
|
if (sliderRef.current) {
|
||||||
@@ -723,11 +592,49 @@ const SliderVerify: React.FC<{
|
|||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener('mousemove', handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
if (successRef.current || isVerified) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const startX = touch.clientX - rect.left - sliderWidth / 2;
|
||||||
|
updatePosition(startX);
|
||||||
|
|
||||||
|
const handleTouchMove = (moveEvent: TouchEvent) => {
|
||||||
|
const moveTouch = moveEvent.touches[0];
|
||||||
|
const x = moveTouch.clientX - rect.left - sliderWidth / 2;
|
||||||
|
updatePosition(x);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
document.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
document.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
|
||||||
|
if (!successRef.current && positionRef.current < maxPosition - 5) {
|
||||||
|
positionRef.current = 0;
|
||||||
|
if (sliderRef.current) {
|
||||||
|
sliderRef.current.style.left = '0px';
|
||||||
|
}
|
||||||
|
if (trackRef.current) {
|
||||||
|
trackRef.current.style.width = `${sliderWidth}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', handleTouchEnd);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: 50,
|
height: 50,
|
||||||
@@ -742,7 +649,6 @@ const SliderVerify: React.FC<{
|
|||||||
touchAction: 'none',
|
touchAction: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 已滑动部分背景 - 包含阴影弧度 */}
|
|
||||||
<div
|
<div
|
||||||
ref={trackRef}
|
ref={trackRef}
|
||||||
style={{
|
style={{
|
||||||
@@ -762,7 +668,6 @@ const SliderVerify: React.FC<{
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 文字提示 */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -793,7 +698,6 @@ const SliderVerify: React.FC<{
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 滑块 */}
|
|
||||||
<div
|
<div
|
||||||
ref={sliderRef}
|
ref={sliderRef}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
Reference in New Issue
Block a user