1
This commit is contained in:
@@ -0,0 +1,631 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
WalletOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
ThunderboltOutlined,
|
||||
HomeOutlined,
|
||||
LockOutlined,
|
||||
PlusCircleOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
PlusOutlined,
|
||||
GiftOutlined,
|
||||
BellOutlined,
|
||||
StarFilled,
|
||||
FireFilled,
|
||||
CrownFilled,
|
||||
BankFilled,
|
||||
CloseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/useAuthStore';
|
||||
import { getMenuConfigs, getRechargePackages, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
|
||||
import NotificationPopup from '../NotificationPopup';
|
||||
|
||||
interface MenuConfig {
|
||||
id: string;
|
||||
key?: string;
|
||||
label: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
parentId?: string | null;
|
||||
parent_id?: string | null;
|
||||
menuType?: string;
|
||||
menu_type?: string;
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
PlayCircleOutlined: <PlayCircleOutlined />,
|
||||
WalletOutlined: <WalletOutlined />,
|
||||
SettingOutlined: <LockOutlined />,
|
||||
BellOutlined: <GiftOutlined />,
|
||||
UserOutlined: <UserOutlined />,
|
||||
AppstoreOutlined: <FireFilled />,
|
||||
FileTextOutlined: <FireFilled />,
|
||||
StarOutlined: <StarFilled />,
|
||||
HeartOutlined: <FireFilled />,
|
||||
CameraOutlined: <PlayCircleOutlined />,
|
||||
};
|
||||
|
||||
const EXPANDED_W = 240;
|
||||
const COLLAPSED_W = 68;
|
||||
|
||||
const GRADIENTS = [
|
||||
{ gradient: 'linear-gradient(135deg, #f59e0b, #f97316)', shadow: 'rgba(245,158,11,0.3)', icon: <StarFilled /> },
|
||||
{ gradient: 'linear-gradient(135deg, #6366f1, #8b5cf6)', shadow: 'rgba(99,102,241,0.3)', icon: <FireFilled /> },
|
||||
{ gradient: 'linear-gradient(135deg, #06b6d4, #0ea5e9)', shadow: 'rgba(6,182,212,0.3)', icon: <CrownFilled /> },
|
||||
{ gradient: 'linear-gradient(135deg, #10b981, #059669)', shadow: 'rgba(16,185,129,0.3)', icon: <BankFilled /> },
|
||||
];
|
||||
|
||||
const AppLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
||||
const [rechargeModalOpen, setRechargeModalOpen] = useState(false);
|
||||
const [pwdForm] = Form.useForm();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [toggleHover, setToggleHover] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<number | null>(null);
|
||||
const [menuItems, setMenuItems] = useState<MenuConfig[]>([]);
|
||||
const [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
const [msgModalOpen, setMsgModalOpen] = useState(false);
|
||||
const [allNotifications, setAllNotifications] = useState<any[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [siteName, setSiteName] = useState('VideoGen.AI');
|
||||
const [siteLogo, setSiteLogo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getSiteInfo().then(info => {
|
||||
setSiteName(info.siteName || 'VideoGen.AI');
|
||||
setSiteLogo(info.siteLogo || '');
|
||||
document.title = info.siteName || 'VideoGen.AI';
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const loadNotifications = () => {
|
||||
getNotifications().then(data => {
|
||||
setAllNotifications(data);
|
||||
setUnreadCount(data.filter((n: any) => !(n.isRead ?? n.is_read)).length);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getMenuConfigs().then(data => {
|
||||
let items = data.filter((m: any) => m.is_active !== false && m.isActive !== false);
|
||||
// Filter by user's allowed menus if set
|
||||
if (user?.allowedMenus && user.allowedMenus.length > 0) {
|
||||
const allowed = new Set(user.allowedMenus);
|
||||
const groupIds = new Set<string>();
|
||||
items.forEach((m: any) => {
|
||||
const pid = m.parent_id ?? m.parentId;
|
||||
if (pid && allowed.has(m.path)) groupIds.add(pid);
|
||||
});
|
||||
items = items.filter((m: any) => {
|
||||
const mt = m.menu_type ?? m.menuType;
|
||||
if (mt === 'group' && groupIds.has(m.id)) return true;
|
||||
return allowed.has(m.path);
|
||||
});
|
||||
}
|
||||
setMenuItems(items);
|
||||
}).catch(() => {});
|
||||
getRechargePackages().then(data => {
|
||||
setRechargeOptions(data.filter((p: any) => p.is_active !== false && p.isActive !== false));
|
||||
}).catch(() => {});
|
||||
loadNotifications();
|
||||
}, [user]);
|
||||
|
||||
const selectedKey = location.pathname.startsWith('/records') ? '/records' : location.pathname;
|
||||
const sidebarW = collapsed ? COLLAPSED_W : EXPANDED_W;
|
||||
|
||||
const userMenuItems = [
|
||||
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, disabled: true },
|
||||
{ key: 'credits', icon: <WalletOutlined />, label: `积分余额: ${user?.credits ?? 0}`, disabled: true },
|
||||
{ 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); }
|
||||
};
|
||||
|
||||
const handleChangePwd = async () => {
|
||||
try {
|
||||
await pwdForm.validateFields();
|
||||
message.success('密码修改成功(演示)');
|
||||
setPwdModalOpen(false); pwdForm.resetFields();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleMobileRecharge = () => {
|
||||
setRechargeModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="desktop-sidebar" style={{
|
||||
width: sidebarW, position: 'fixed', left: 0, top: 0, bottom: 0, zIndex: 100,
|
||||
background: 'linear-gradient(180deg, #0f0f23 0%, #1a1a35 100%)',
|
||||
borderRight: '1px solid rgba(255,255,255,0.05)',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
transition: 'width 0.25s ease',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Logo + Toggle */}
|
||||
<div style={{
|
||||
height: 72, display: 'flex', alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'space-between',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.06)',
|
||||
padding: collapsed ? '0' : '0 16px 0 20px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, overflow: 'hidden' }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 4px 12px rgba(99,102,241,0.3)', overflow: 'hidden',
|
||||
}}>
|
||||
{siteLogo ? (
|
||||
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<ThunderboltOutlined style={{ fontSize: 18, color: '#fff' }} />
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span style={{
|
||||
color: '#f1f5f9', fontSize: 17, fontWeight: 800, letterSpacing: -0.5,
|
||||
whiteSpace: 'nowrap', opacity: collapsed ? 0 : 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
}}>
|
||||
{siteName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credits pill */}
|
||||
<div onClick={() => navigate('/credits')} style={{
|
||||
margin: collapsed ? '16px auto 4px' : '16px 14px 4px',
|
||||
padding: collapsed ? '10px' : '12px 14px',
|
||||
borderRadius: 12,
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.12), rgba(139,92,246,0.12))',
|
||||
border: '1px solid rgba(99,102,241,0.15)', cursor: 'pointer', transition: 'all 0.25s',
|
||||
textAlign: collapsed ? 'center' : 'left',
|
||||
}}>
|
||||
{collapsed ? (
|
||||
<WalletOutlined style={{ color: '#818cf8', fontSize: 18 }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<WalletOutlined style={{ color: '#818cf8', fontSize: 13 }} />
|
||||
<span style={{ color: 'rgba(203,213,225,0.6)', fontSize: 12 }}>可用积分</span>
|
||||
</div>
|
||||
<span style={{ color: '#fff', fontSize: 22, fontWeight: 800 }}>{user?.credits ?? 0}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recharge button - opens modal */}
|
||||
<div onClick={() => setRechargeModalOpen(true)} style={{
|
||||
margin: collapsed ? '8px auto 4px' : '8px 14px 4px',
|
||||
padding: collapsed ? '10px' : '10px 14px',
|
||||
borderRadius: 12, cursor: 'pointer', textAlign: 'center',
|
||||
background: 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))',
|
||||
border: '1px solid rgba(99,102,241,0.25)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.3), rgba(139,92,246,0.3))'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.2))'; }}
|
||||
>
|
||||
<PlusOutlined style={{ color: '#818cf8', fontSize: 13 }} />
|
||||
{!collapsed && <span style={{ color: '#818cf8', fontSize: 13, fontWeight: 600 }}>充值积分</span>}
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<div style={{ flex: 1, padding: '8px 8px', overflow: 'hidden' }}>
|
||||
{!collapsed && (
|
||||
<div style={{ color: 'rgba(148,163,184,0.4)', fontSize: 11, fontWeight: 600, padding: '8px 12px 6px', letterSpacing: 1 }}>
|
||||
导航
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const groups = menuItems.filter(m => (m.menu_type ?? m.menuType) === 'group');
|
||||
const pages = menuItems.filter(m => (m.menu_type ?? m.menuType) !== 'group');
|
||||
const childMap: Record<string, MenuConfig[]> = {};
|
||||
pages.filter(m => m.parent_id ?? m.parentId).forEach(m => {
|
||||
const pid = (m.parent_id ?? m.parentId) as string;
|
||||
if (!childMap[pid]) childMap[pid] = [];
|
||||
childMap[pid].push(m);
|
||||
});
|
||||
const topLevel = pages.filter(m => !(m.parent_id ?? m.parentId));
|
||||
const items: React.ReactNode[] = [];
|
||||
|
||||
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
|
||||
const isActive = item.path === selectedKey;
|
||||
const menuIcon = iconMap[item.icon] || <HomeOutlined />;
|
||||
const el = (
|
||||
<div key={item.id} onClick={() => item.path && navigate(item.path)} style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
gap: collapsed ? 0 : 10,
|
||||
padding: collapsed ? '10px 0' : depth > 0 ? '8px 14px 8px 32px' : '10px 14px',
|
||||
borderRadius: 10, margin: '2px 0', cursor: 'pointer',
|
||||
fontSize: depth > 0 ? 13 : 14, fontWeight: isActive ? 600 : 400,
|
||||
color: isActive ? '#fff' : 'rgba(148,163,184,0.75)',
|
||||
background: isActive ? 'linear-gradient(135deg, rgba(99,102,241,0.2), rgba(139,92,246,0.15))' : 'transparent',
|
||||
border: isActive ? '1px solid rgba(99,102,241,0.2)' : '1px solid transparent',
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<span style={{ fontSize: depth > 0 ? 15 : 17, flexShrink: 0 }}>{menuIcon}</span>
|
||||
{!collapsed && <span style={{ whiteSpace: 'nowrap' }}>{item.label}</span>}
|
||||
</div>
|
||||
);
|
||||
return collapsed ? <Tooltip key={item.id} title={item.label} placement="right">{el}</Tooltip> : el;
|
||||
};
|
||||
|
||||
// Render groups with children
|
||||
groups.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(g => {
|
||||
const children = (childMap[g.id] || []).sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
if (!collapsed) {
|
||||
const isGroupOpen = !collapsedGroups[g.id];
|
||||
items.push(
|
||||
<div key={g.id}>
|
||||
<div onClick={() => setCollapsedGroups(prev => ({ ...prev, [g.id]: !prev[g.id] }))} style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '8px 14px', margin: '8px 0 2px', cursor: 'pointer',
|
||||
color: 'rgba(148,163,184,0.5)', fontSize: 11, fontWeight: 600,
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
<span>{g.label}</span>
|
||||
<span style={{ fontSize: 10, transform: isGroupOpen ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s' }}>▶</span>
|
||||
</div>
|
||||
{isGroupOpen && children.map(c => renderMenuItem(c, 1))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
children.forEach(c => items.push(renderMenuItem(c)));
|
||||
}
|
||||
});
|
||||
|
||||
// Render top-level pages
|
||||
topLevel.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)).forEach(m => {
|
||||
items.push(renderMenuItem(m));
|
||||
});
|
||||
|
||||
return items;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Message button */}
|
||||
<div onClick={() => { setMsgModalOpen(true); loadNotifications(); }} style={{
|
||||
margin: collapsed ? '4px auto' : '4px 14px',
|
||||
padding: collapsed ? '10px' : '10px 14px',
|
||||
borderRadius: 12, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
gap: collapsed ? 0 : 10,
|
||||
color: 'rgba(148,163,184,0.75)', fontSize: 14,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.06)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.03)'; }}
|
||||
>
|
||||
<span style={{ position: 'relative' }}>
|
||||
<BellOutlined style={{ fontSize: 16 }} />
|
||||
{unreadCount > 0 && (
|
||||
<span style={{
|
||||
position: 'absolute', top: -6, right: -8,
|
||||
background: '#ef4444', color: '#fff', fontSize: 10, fontWeight: 700,
|
||||
borderRadius: 10, padding: '0 5px', lineHeight: '16px', minWidth: 16, textAlign: 'center',
|
||||
}}>{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||
)}
|
||||
</span>
|
||||
{!collapsed && <span>消息中心</span>}
|
||||
</div>
|
||||
|
||||
{/* User block at bottom-left */}
|
||||
<div style={{ padding: collapsed ? '12px 8px' : '14px 14px', borderTop: '1px solid rgba(255,255,255,0.06)', flexShrink: 0 }}>
|
||||
<Dropdown menu={{ items: userMenuItems, onClick: handleUserMenuClick }} placement="topRight" arrow>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
gap: collapsed ? 0 : 10,
|
||||
padding: collapsed ? '8px' : '8px 10px',
|
||||
borderRadius: 12, cursor: 'pointer', transition: 'background 0.2s',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.06)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'rgba(255,255,255,0.03)'}
|
||||
>
|
||||
<Avatar size={32} icon={<UserOutlined />}
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', flexShrink: 0 }} />
|
||||
{!collapsed && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: '#e2e8f0', fontSize: 13, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{user?.username}
|
||||
</div>
|
||||
<div style={{ color: 'rgba(148,163,184,0.5)', fontSize: 11 }}>查看账号</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating sidebar toggle hover zone */}
|
||||
<div
|
||||
onMouseEnter={() => setToggleHover(true)}
|
||||
onMouseLeave={() => setToggleHover(false)}
|
||||
style={{
|
||||
position: 'fixed', left: sidebarW - 16, top: 0, bottom: 0,
|
||||
zIndex: 110, width: 32, cursor: 'default',
|
||||
}}
|
||||
>
|
||||
{toggleHover && (
|
||||
<div onClick={() => setCollapsed(!collapsed)} style={{
|
||||
position: 'absolute', left: '50%', top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 24, height: 56, borderRadius: '0 8px 8px 0',
|
||||
background: '#1a1a35', border: '1px solid rgba(255,255,255,0.1)', borderLeft: 'none',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
||||
boxShadow: '2px 0 12px rgba(0,0,0,0.2)',
|
||||
animation: 'fadeInRight 0.2s ease both',
|
||||
}}>
|
||||
{collapsed
|
||||
? <RightOutlined style={{ color: '#818cf8', fontSize: 12 }} />
|
||||
: <LeftOutlined style={{ color: '#818cf8', fontSize: 12 }} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="desktop-content" style={{
|
||||
marginLeft: sidebarW, flex: 1, minHeight: '100vh', background: '#f5f6fa',
|
||||
padding: '24px 32px 32px', transition: 'margin-left 0.25s ease',
|
||||
}}>
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
{/* Mobile Bottom Nav */}
|
||||
<div className="mobile-bottom-nav">
|
||||
{menuItems.map((item) => {
|
||||
const isActive = item.path === selectedKey;
|
||||
return (
|
||||
<div key={item.id}
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => navigate(item.path)}>
|
||||
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="nav-item" onClick={handleMobileRecharge}>
|
||||
<span className="nav-icon"><PlusCircleOutlined /></span>
|
||||
<span>充值</span>
|
||||
</div>
|
||||
<div className="nav-item" onClick={handleLogout}>
|
||||
<span className="nav-icon"><LogoutOutlined /></span>
|
||||
<span>退出</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Change Password Modal */}
|
||||
<Modal title={<Space><LockOutlined />修改密码</Space>} open={pwdModalOpen}
|
||||
onOk={handleChangePwd} onCancel={() => { setPwdModalOpen(false); pwdForm.resetFields(); }}
|
||||
okText="确认修改" cancelText="取消" width={440}>
|
||||
<Form form={pwdForm} layout="vertical" style={{ marginTop: 20 }}>
|
||||
<Form.Item name="oldPwd" label="原密码" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||
<Input.Password placeholder="请输入原密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
|
||||
</Form.Item>
|
||||
<Form.Item name="newPwd" label="新密码" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码至少6位' }]}>
|
||||
<Input.Password placeholder="请输入新密码(至少6位)" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirmPwd" label="确认新密码" rules={[
|
||||
{ required: true, message: '请再次输入新密码' },
|
||||
({ getFieldValue }: any) => ({
|
||||
validator(_: any, value: string) {
|
||||
if (!value || getFieldValue('newPwd') === value) return Promise.resolve();
|
||||
return Promise.reject(new Error('两次密码不一致'));
|
||||
},
|
||||
}),
|
||||
]}>
|
||||
<Input.Password placeholder="请再次输入新密码" size="large" prefix={<LockOutlined style={{ color: '#94a3b8', marginRight: 8 }} />} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Recharge Modal */}
|
||||
<Modal title={<Space><GiftOutlined />积分充值</Space>} open={rechargeModalOpen}
|
||||
onCancel={() => { setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||
footer={null} width={680}>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<WalletOutlined style={{ color: '#6366f1' }} />
|
||||
<Typography.Text style={{ color: '#64748b' }}>当前积分余额:</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#6366f1', fontSize: 18 }}>{user?.credits ?? 0}</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{rechargeOptions.map((opt, idx) => {
|
||||
const g = GRADIENTS[idx % GRADIENTS.length];
|
||||
const totalCredits = (opt.credits || 0) + (opt.bonus_credits || opt.bonusCredits || 0);
|
||||
return (
|
||||
<div key={opt.id} onClick={() => setSelectedPlan(opt.id)} style={{
|
||||
flex: '1 1 45%', minWidth: 200, borderRadius: 16, padding: '20px 16px',
|
||||
background: selectedPlan === opt.id ? 'rgba(99,102,241,0.04)' : '#fafbff',
|
||||
border: selectedPlan === opt.id ? '2px solid #6366f1' : '1px solid #f0f0f5',
|
||||
cursor: 'pointer', position: 'relative', transition: 'all 0.2s',
|
||||
}}>
|
||||
{opt.description && (
|
||||
<Tag color="purple" style={{ position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)', borderRadius: 8, fontSize: 11 }}>{opt.description}</Tag>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 12, flexShrink: 0,
|
||||
background: g.gradient, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#fff', boxShadow: `0 6px 16px ${g.shadow}`,
|
||||
}}>{g.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>{opt.name}</Typography.Text>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>{totalCredits.toLocaleString()} 积分</Typography.Text>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 22, fontWeight: 800, marginTop: 4,
|
||||
background: g.gradient, WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent',
|
||||
}}>¥{opt.price}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button size="large" onClick={() => { setRechargeModalOpen(false); setSelectedPlan(null); }} style={{ borderRadius: 10, marginRight: 12 }}>取消</Button>
|
||||
<Button type="primary" size="large" disabled={!selectedPlan}
|
||||
onClick={() => { message.success('支付功能对接后端后开放'); setRechargeModalOpen(false); setSelectedPlan(null); }}
|
||||
style={{
|
||||
borderRadius: 10, fontWeight: 600,
|
||||
background: selectedPlan ? 'linear-gradient(135deg, #6366f1, #8b5cf6)' : '#d1d5db',
|
||||
border: 'none', boxShadow: selectedPlan ? '0 8px 24px rgba(99,102,241,0.3)' : 'none',
|
||||
}}>
|
||||
确认充值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Message Center Modal */}
|
||||
<Modal
|
||||
open={msgModalOpen}
|
||||
onCancel={() => { setMsgModalOpen(false); }}
|
||||
footer={null} width={520}
|
||||
closable={false}
|
||||
styles={{
|
||||
body: { padding: 0, borderRadius: 16, overflow: 'hidden' },
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
padding: '20px 24px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10,
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}>
|
||||
<BellOutlined style={{ fontSize: 18, color: '#fff' }} />
|
||||
</div>
|
||||
<span style={{ color: '#fff', fontSize: 16, fontWeight: 700 }}>消息中心</span>
|
||||
{unreadCount > 0 && (
|
||||
<span style={{
|
||||
background: 'rgba(255,255,255,0.25)', color: '#fff',
|
||||
fontSize: 11, fontWeight: 600, borderRadius: 10,
|
||||
padding: '2px 10px', backdropFilter: 'blur(4px)',
|
||||
}}>{unreadCount} 条未读</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="text" icon={<CloseOutlined style={{ color: 'rgba(255,255,255,0.8)', fontSize: 16 }} />}
|
||||
onClick={() => setMsgModalOpen(false)}
|
||||
style={{ color: '#fff' }} />
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div style={{ maxHeight: 460, overflow: 'auto', padding: '12px 16px 16px' }}>
|
||||
{allNotifications.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<BellOutlined style={{ fontSize: 40, color: '#cbd5e1', marginBottom: 12 }} />
|
||||
<div style={{ color: '#94a3b8', fontSize: 14 }}>暂无消息</div>
|
||||
</div>
|
||||
) : (
|
||||
allNotifications.map((n: any) => {
|
||||
const isRead = n.isRead ?? n.is_read;
|
||||
const typeConfig: Record<string, { color: string; bg: string; label: string }> = {
|
||||
system: { color: '#6366f1', bg: 'rgba(99,102,241,0.1)', label: '系统' },
|
||||
credit: { color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', label: '积分' },
|
||||
promo: { color: '#8b5cf6', bg: 'rgba(139,92,246,0.1)', label: '活动' },
|
||||
};
|
||||
const tc = typeConfig[n.type] || typeConfig.system;
|
||||
return (
|
||||
<div key={n.id} style={{
|
||||
padding: '16px', borderRadius: 12, marginBottom: 8,
|
||||
background: isRead ? '#fff' : 'linear-gradient(135deg, rgba(99,102,241,0.03), rgba(139,92,246,0.03))',
|
||||
border: isRead ? '1px solid #f0f0f5' : '1px solid rgba(99,102,241,0.18)',
|
||||
cursor: isRead ? 'default' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
position: 'relative',
|
||||
}} onClick={async () => {
|
||||
if (!isRead) {
|
||||
await markNotificationRead(n.id);
|
||||
loadNotifications();
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.04)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{/* Type icon */}
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 10, flexShrink: 0,
|
||||
background: tc.bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<BellOutlined style={{ color: tc.color, fontSize: 15 }} />
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{!isRead && <span style={{
|
||||
width: 7, height: 7, borderRadius: '50%',
|
||||
background: '#6366f1', flexShrink: 0,
|
||||
boxShadow: '0 0 6px rgba(99,102,241,0.4)',
|
||||
}} />}
|
||||
<span style={{ fontWeight: 600, fontSize: 14, color: '#1e293b' }}>{n.title}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: '#94a3b8', flexShrink: 0 }}>
|
||||
{n.createdAt ? n.createdAt.replace('T', ' ').slice(0, 16) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 13, color: '#64748b', lineHeight: 1.7,
|
||||
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
||||
}}>{n.content}</div>
|
||||
<Tag color={tc.color} style={{ marginTop: 8, borderRadius: 6, border: 'none', fontSize: 11, padding: '1px 8px' }}>
|
||||
{tc.label}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<NotificationPopup />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Tag, Typography, Button } from 'antd';
|
||||
import { BellOutlined, ThunderboltOutlined, GiftOutlined, StarOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { getNotifications, markNotificationRead } from '../api';
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
type: string;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const typeConfig: Record<string, { gradient: string; icon: React.ReactNode; label: string }> = {
|
||||
system: { gradient: 'linear-gradient(135deg, #6366f1, #818cf8)', icon: <ThunderboltOutlined />, label: '系统通知' },
|
||||
credit: { gradient: 'linear-gradient(135deg, #f59e0b, #fbbf24)', icon: <StarOutlined />, label: '积分通知' },
|
||||
promo: { gradient: 'linear-gradient(135deg, #8b5cf6, #a78bfa)', icon: <GiftOutlined />, label: '活动通知' },
|
||||
};
|
||||
|
||||
const NotificationPopup: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const data = await getNotifications();
|
||||
const unread = data.filter((n: Notification) => !n.isRead);
|
||||
setNotifications(unread);
|
||||
if (unread.length > 0 && !visible) {
|
||||
setVisible(true);
|
||||
setCurrentIndex(0);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
const timer = setInterval(fetchNotifications, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const handleAcknowledge = async () => {
|
||||
const current = notifications[currentIndex];
|
||||
if (current) {
|
||||
try { await markNotificationRead(current.id); } catch { /* ignore */ }
|
||||
}
|
||||
if (currentIndex < notifications.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
} else {
|
||||
setVisible(false);
|
||||
setNotifications([]);
|
||||
setCurrentIndex(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
const current = notifications[currentIndex];
|
||||
if (current) {
|
||||
try { await markNotificationRead(current.id); } catch { /* ignore */ }
|
||||
}
|
||||
setVisible(false);
|
||||
setNotifications([]);
|
||||
setCurrentIndex(0);
|
||||
};
|
||||
|
||||
if (!visible || notifications.length === 0) return null;
|
||||
|
||||
const current = notifications[currentIndex];
|
||||
const tc = typeConfig[current.type] || typeConfig.system;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'rgba(15,15,35,0.6)', backdropFilter: 'blur(8px)',
|
||||
}} onClick={handleClose}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
width: 420, borderRadius: 20, overflow: 'hidden',
|
||||
boxShadow: '0 32px 80px rgba(0,0,0,0.35)',
|
||||
animation: 'slideUp 0.35s cubic-bezier(0.16,1,0.3,1)',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
background: tc.gradient,
|
||||
padding: '28px 28px 24px',
|
||||
position: 'relative', overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ position: 'absolute', right: -20, top: -20, width: 120, height: 120, borderRadius: '50%', background: 'rgba(255,255,255,0.1)' }} />
|
||||
<div style={{ position: 'absolute', right: 50, bottom: -30, width: 80, height: 80, borderRadius: '50%', background: 'rgba(255,255,255,0.06)' }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
||||
<div style={{
|
||||
width: 48, height: 48, borderRadius: 14,
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
backdropFilter: 'blur(4px)', fontSize: 22, color: '#fff',
|
||||
}}>{tc.icon}</div>
|
||||
<div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginBottom: 2 }}>新消息通知</div>
|
||||
<div style={{ color: '#fff', fontSize: 17, fontWeight: 700 }}>{current.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={handleClose} style={{
|
||||
width: 28, height: 28, borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.15)', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
|
||||
>
|
||||
<CloseOutlined style={{ color: '#fff', fontSize: 12 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ background: '#fff', padding: '24px 28px 28px' }}>
|
||||
<div style={{
|
||||
padding: '18px 20px', background: '#f8fafc', borderRadius: 14,
|
||||
marginBottom: 20, border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<div style={{ fontSize: 14, color: '#334155', lineHeight: 1.8 }}>
|
||||
{current.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Tag color={tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6'}
|
||||
style={{ borderRadius: 6, border: 'none', padding: '2px 10px', fontSize: 12 }}>
|
||||
{tc.label}
|
||||
</Tag>
|
||||
{notifications.length > 1 && (
|
||||
<span style={{ fontSize: 12, color: '#94a3b8' }}>{currentIndex + 1} / {notifications.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button type="primary" onClick={handleAcknowledge} style={{
|
||||
borderRadius: 10, fontWeight: 600, height: 38,
|
||||
background: tc.gradient, border: 'none',
|
||||
paddingLeft: 28, paddingRight: 28,
|
||||
boxShadow: `0 6px 16px ${tc.gradient.includes('#6366f1') ? 'rgba(99,102,241,0.3)' : tc.gradient.includes('#f59e0b') ? 'rgba(245,158,11,0.3)' : 'rgba(139,92,246,0.3)'}`,
|
||||
}}>
|
||||
我已知晓
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Dots */}
|
||||
{notifications.length > 1 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 6, marginTop: 18 }}>
|
||||
{notifications.map((_, i) => (
|
||||
<div key={i} style={{
|
||||
width: i === currentIndex ? 22 : 6, height: 6, borderRadius: 3,
|
||||
background: i === currentIndex ? (tc.gradient.includes('#6366f1') ? '#6366f1' : tc.gradient.includes('#f59e0b') ? '#f59e0b' : '#8b5cf6') : '#e2e8f0',
|
||||
transition: 'all 0.3s ease',
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(24px) scale(0.97); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationPopup;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
interface SliderCaptchaProps {
|
||||
onVerify: (x: number) => Promise<boolean>;
|
||||
onSuccess: () => void;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onSuccess, width = 320 }) => {
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [x, setX] = useState(0);
|
||||
const [verified, setVerified] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sliderW = 44;
|
||||
const maxDrag = width - sliderW;
|
||||
|
||||
const handleMove = useCallback((clientX: number) => {
|
||||
if (!containerRef.current || !dragging) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const raw = clientX - rect.left - sliderW / 2;
|
||||
setX(Math.max(0, Math.min(raw, maxDrag)));
|
||||
}, [dragging, maxDrag]);
|
||||
|
||||
const handleEnd = useCallback(async () => {
|
||||
if (!dragging) return;
|
||||
setDragging(false);
|
||||
const ratio = x / maxDrag;
|
||||
const pixelX = Math.round(ratio * 260);
|
||||
const ok = await onVerify(pixelX);
|
||||
if (ok) {
|
||||
setVerified(true);
|
||||
onSuccess();
|
||||
} else {
|
||||
setFailed(true);
|
||||
setX(0);
|
||||
setTimeout(() => setFailed(false), 600);
|
||||
}
|
||||
}, [dragging, x, maxDrag, onVerify, onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMouseMove = (e: MouseEvent) => handleMove(e.clientX);
|
||||
const onTouchMove = (e: TouchEvent) => handleMove(e.touches[0].clientX);
|
||||
const onUp = () => handleEnd();
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
window.addEventListener('touchmove', onTouchMove);
|
||||
window.addEventListener('touchend', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('touchend', onUp);
|
||||
};
|
||||
}, [dragging, handleMove, handleEnd]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{
|
||||
width, height: 44, borderRadius: 8, position: 'relative', userSelect: 'none',
|
||||
background: verified ? '#ecfdf5' : failed ? '#fef2f2' : '#f1f5f9',
|
||||
border: `1px solid ${verified ? '#10b981' : failed ? '#ef4444' : '#e2e8f0'}`,
|
||||
transition: 'all 0.3s',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Track text */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: verified ? '#10b981' : failed ? '#ef4444' : '#94a3b8',
|
||||
fontSize: 13, fontWeight: 500,
|
||||
transition: 'color 0.3s',
|
||||
}}>
|
||||
{verified ? '验证通过' : failed ? '验证失败,请重试' : '请按住滑块,拖动到最右边'}
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div
|
||||
onMouseDown={() => !verified && setDragging(true)}
|
||||
onTouchStart={() => !verified && setDragging(true)}
|
||||
style={{
|
||||
position: 'absolute', left: x, top: 0,
|
||||
width: sliderW, height: '100%',
|
||||
borderRadius: 8,
|
||||
background: verified
|
||||
? 'linear-gradient(135deg, #10b981, #059669)'
|
||||
: failed
|
||||
? 'linear-gradient(135deg, #ef4444, #dc2626)'
|
||||
: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: verified ? 'default' : 'grab',
|
||||
boxShadow: dragging ? '0 4px 12px rgba(99,102,241,0.3)' : 'none',
|
||||
transition: dragging ? 'none' : 'left 0.3s ease, background 0.3s',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M6 3l5 5-5 5" stroke="#fff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Progress fill */}
|
||||
{!verified && (
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0,
|
||||
width: x + sliderW / 2,
|
||||
background: failed ? 'rgba(239,68,68,0.06)' : 'rgba(99,102,241,0.06)',
|
||||
borderRadius: '8px 0 0 8px',
|
||||
transition: dragging ? 'none' : 'width 0.3s',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SliderCaptcha;
|
||||
Reference in New Issue
Block a user