This commit is contained in:
2026-06-26 16:21:26 +08:00
parent 30e263e7d7
commit 14dae263bb
8 changed files with 247 additions and 258 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -28,8 +28,8 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-CW4DG5sI.js"></script> <script type="module" crossorigin src="/assets/index-tvhdMiao.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hHWRoeqc.css"> <link rel="stylesheet" crossorigin href="/assets/index-D0-PljDg.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+32 -7
View File
@@ -241,18 +241,43 @@
@media (max-width: 480px) { @media (max-width: 480px) {
.login-left-section { .login-left-section {
padding: 20px 16px 12px !important; padding: 20px 12px 12px;
text-align: center; text-align: center;
} }
.login-left-section > div > div:first-of-type {
justify-content: center;
}
.login-right-section { .login-right-section {
padding: 12px 16px 24px !important; padding: 12px 12px 24px;
align-items: flex-start; align-items: flex-start;
} }
.login-right-section .ant-card {
.login-container {
background-attachment: scroll;
background-position: center top;
}
.login-card {
max-width: 100% !important; max-width: 100% !important;
width: 100% !important; border-radius: 12px !important;
}
.login-card .ant-card-body {
padding: 20px 16px !important;
}
.shiny-text-container {
font-size: 16px;
}
.shiny-text {
font-size: 18px;
}
.login-brand-name {
font-size: 20px !important;
}
.login-logo {
width: 36px !important;
height: 36px !important;
} }
} }
+89 -110
View File
@@ -138,10 +138,8 @@ const AppLayout: React.FC = () => {
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 [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
const [mobileDrawerParent, setMobileDrawerParent] = useState<MenuConfig | null>(null);
const [mobileDrawerChildren, setMobileDrawerChildren] = useState<MenuConfig[]>([]);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [mobileExpandedParent, setMobileExpandedParent] = useState<MenuConfig | null>(null);
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 || '' : '';
@@ -286,6 +284,24 @@ 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;
// Compute menu hierarchy (used by both desktop sidebar and mobile nav)
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;
});
// For mobile nav: show top-level items that either have a path or have children
const topLevelForNav = topLevelItems.filter(m => m.path || (childMap[m.id] && childMap[m.id].length > 0));
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 },
@@ -309,35 +325,6 @@ const AppLayout: React.FC = () => {
else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); } else if (key === 'orderRecords') { navigate('/user-center?tab=orders'); }
}; };
// Mobile two-level menu: open drawer with children
const handleMobileNavClick = (item: MenuConfig) => {
const children = menuItems.filter(m => (m.parent_id ?? m.parentId) === item.id);
if (children.length > 0) {
setMobileDrawerParent(item);
setMobileDrawerChildren(children.sort((a, b) => {
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
return orderA - orderB;
}));
setMobileDrawerOpen(true);
} else if (item.path) {
navigate(item.path);
}
};
const handleMobileChildClick = (item: MenuConfig) => {
if (item.path) {
setMobileDrawerOpen(false);
setMobileDrawerParent(null);
navigate(item.path);
}
};
const closeMobileDrawer = () => {
setMobileDrawerOpen(false);
setMobileDrawerParent(null);
};
const handleChangePwd = async () => { const handleChangePwd = async () => {
try { try {
await pwdForm.validateFields(); await pwdForm.validateFields();
@@ -493,23 +480,7 @@ const AppLayout: React.FC = () => {
{/* Menu */} {/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}> <div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{(() => { {(() => {
const childMap: Record<string, MenuConfig[]> = {}; // childMap, topLevelItems are computed at component level above
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;
});
const items: React.ReactNode[] = []; const items: React.ReactNode[] = [];
const renderMenuItem = (item: MenuConfig, depth: number = 0) => { const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
@@ -742,69 +713,77 @@ const AppLayout: React.FC = () => {
</div> </div>
</div> </div>
{/* Mobile Bottom Nav */} {/* Mobile Bottom Nav - Two Level */}
<div className="mobile-bottom-nav"> <div className="mobile-bottom-nav-wrapper">
{menuItems.filter((item) => { {/* Sub-menu panel (level-2) */}
if ((item.menu_type ?? item.menuType) === 'group') return false; {mobileExpandedParent && (
// Only show top-level items (no parent) <div className="mobile-submenu-panel">
if (item.parent_id || item.parentId) return false; <div className="mobile-submenu-header" onClick={() => setMobileExpandedParent(null)}>
return true; <LeftOutlined style={{ fontSize: 16, color: '#6366f1' }} />
}).sort((a, b) => { <span className="mobile-submenu-title">{mobileExpandedParent.label}</span>
const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
return orderA - orderB;
}).map((item) => {
const isActive = item.path === selectedKey || menuItems.some(m =>
(m.parent_id ?? m.parentId) === item.id && m.path === selectedKey
);
return (
<div key={item.id}
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => handleMobileNavClick(item)}>
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
<span>{item.label}</span>
</div> </div>
); <div className="mobile-submenu-list">
})} {(childMap[mobileExpandedParent.id] || []).sort((a, b) => {
<div className="nav-item" onClick={handleMobileRecharge}> const orderA = typeof a.sortOrder === 'number' ? a.sortOrder : Infinity;
<span className="nav-icon"><PlusCircleOutlined /></span> const orderB = typeof b.sortOrder === 'number' ? b.sortOrder : Infinity;
<span></span> return orderA - orderB;
</div> }).map(child => (
<div className="nav-item" onClick={handleLogout}> <div
<span className="nav-icon"><LogoutOutlined /></span> key={child.id}
<span>退</span> className={`mobile-submenu-item ${child.path === selectedKey ? 'active' : ''}`}
</div> onClick={() => {
</div> if (child.path) {
navigate(child.path);
setMobileExpandedParent(null);
}
}}
>
<span className="mobile-submenu-icon">{iconMap[child.icon] || <RightOutlined />}</span>
<span>{child.label}</span>
</div>
))}
</div>
</div>
)}
{/* Mobile Menu Drawer (two-level) */} {/* Bottom nav (level-1) */}
<div className={`mobile-menu-overlay ${mobileDrawerOpen ? 'open' : ''}`} onClick={closeMobileDrawer} /> <div className="mobile-bottom-nav">
<div className={`mobile-menu-drawer ${mobileDrawerOpen ? 'open' : ''}`}> {topLevelForNav.map((item) => {
<div className="drawer-handle"><span /></div> const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
<div className="drawer-header"> const isActive = mobileExpandedParent?.id === item.id
{mobileDrawerParent && ( || (!mobileExpandedParent && item.path === selectedKey)
<div className="drawer-back" onClick={() => { setMobileDrawerParent(null); }}> || (!mobileExpandedParent && hasChildren && (childMap[item.id] || []).some(c => c.path === selectedKey));
<LeftOutlined /> return (
</div> <div
)} key={item.id}
<div className="drawer-title">{mobileDrawerParent?.label || '菜单'}</div> className={`nav-item ${isActive ? 'active' : ''}`}
<div style={{ width: 50 }} /> onClick={() => {
</div> if (hasChildren) {
<div className="drawer-body"> setMobileExpandedParent(prev => prev?.id === item.id ? null : item);
{mobileDrawerChildren.map(item => ( } else if (item.path) {
<div navigate(item.path);
key={item.id} }
className="drawer-item" }}
onClick={() => handleMobileChildClick(item)} >
> <span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
<span className="drawer-icon">{iconMap[item.icon] || <RightOutlined />}</span> <span>{item.label}</span>
<span>{item.label}</span> {hasChildren && (
</div> <span className="nav-expand-dot" style={{
))} width: 4, height: 4, borderRadius: '50%',
{mobileDrawerChildren.length === 0 && ( background: isActive ? '#6366f1' : '#cbd5e1',
<div style={{ textAlign: 'center', color: '#94a3b8', padding: '24px 0' }}> }} />
)}
</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> </div>
+82 -97
View File
@@ -213,9 +213,12 @@ html, body {
} }
/* ── Mobile Bottom Nav ─────────────────── */ /* ── Mobile Bottom Nav ─────────────────── */
.mobile-bottom-nav { .mobile-bottom-nav-wrapper {
display: none; display: none;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 200; position: fixed; bottom: 0; left: 0; right: 0; z-index: 200;
}
.mobile-bottom-nav {
display: none;
height: 60px; height: 60px;
background: rgba(255,255,255,0.95); background: rgba(255,255,255,0.95);
backdrop-filter: blur(24px); backdrop-filter: blur(24px);
@@ -228,6 +231,7 @@ html, body {
cursor: pointer; transition: all 0.2s; cursor: pointer; transition: all 0.2s;
color: #8b8fa3; font-size: 9px; font-weight: 400; color: #8b8fa3; font-size: 9px; font-weight: 400;
letter-spacing: 0; letter-spacing: 0;
position: relative;
} }
.mobile-bottom-nav .nav-item.active { .mobile-bottom-nav .nav-item.active {
color: #08080c; color: #08080c;
@@ -238,12 +242,87 @@ html, body {
.mobile-bottom-nav .nav-item.active .nav-icon { .mobile-bottom-nav .nav-item.active .nav-icon {
transform: scale(1.05); transform: scale(1.05);
} }
.mobile-bottom-nav .nav-item .nav-expand-dot {
position: absolute; top: 6px; right: calc(50% - 18px);
}
/* ── Mobile Submenu Panel ───────────────── */
.mobile-submenu-panel {
position: absolute;
bottom: 60px;
left: 0; right: 0;
background: rgba(255,255,255,0.98);
backdrop-filter: blur(24px);
border-top-left-radius: 20px;
border-top-right-radius: 20px;
box-shadow: 0 -8px 32px rgba(0,0,0,0.1);
border: 1px solid rgba(0,0,0,0.05);
border-bottom: none;
max-height: 50vh;
overflow: hidden;
animation: submenuSlideUp 0.25s ease-out;
}
@keyframes submenuSlideUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.mobile-submenu-header {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
cursor: pointer;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
font-weight: 600;
color: #1e293b;
transition: background 0.2s;
}
.mobile-submenu-header:hover {
background: rgba(99,102,241,0.04);
}
.mobile-submenu-title {
flex: 1;
}
.mobile-submenu-list {
padding: 8px 12px 16px;
overflow-y: auto;
max-height: calc(50vh - 48px);
}
.mobile-submenu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
cursor: pointer;
font-size: 14px;
color: #475569;
transition: all 0.2s;
}
.mobile-submenu-item:hover {
background: rgba(99,102,241,0.06);
}
.mobile-submenu-item.active {
background: linear-gradient(135deg, rgba(99,102,241,0.08) 0%, rgba(139,92,246,0.06) 100%);
color: #4f46e5;
font-weight: 500;
}
.mobile-submenu-icon {
font-size: 16px;
color: #64748b;
flex-shrink: 0;
}
.mobile-submenu-item.active .mobile-submenu-icon {
color: #6366f1;
}
/* ── Responsive Breakpoints ────────────── */ /* ── Responsive Breakpoints ────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
.desktop-sidebar { display: none !important; } .desktop-sidebar { display: none !important; }
.desktop-content { margin-left: 0 !important; padding: 16px !important; padding-bottom: 80px !important; overflow-x: hidden !important; } .desktop-content { margin-left: 0 !important; padding: 16px !important; padding-bottom: 80px !important; overflow-x: hidden !important; }
.desktop-sidebar-toggle-zone { display: none !important; } .desktop-sidebar-toggle-zone { display: none !important; }
.mobile-bottom-nav-wrapper { display: block !important; }
.mobile-bottom-nav { display: flex !important; } .mobile-bottom-nav { display: flex !important; }
/* Prevent horizontal overflow globally */ /* Prevent horizontal overflow globally */
@@ -367,6 +446,8 @@ html, body {
.mobile-bottom-nav { height: 56px !important; } .mobile-bottom-nav { height: 56px !important; }
.mobile-bottom-nav .nav-item .nav-icon { font-size: 18px !important; } .mobile-bottom-nav .nav-item .nav-icon { font-size: 18px !important; }
.mobile-bottom-nav .nav-item { font-size: 9px !important; gap: 2px !important; } .mobile-bottom-nav .nav-item { font-size: 9px !important; gap: 2px !important; }
.mobile-submenu-panel { bottom: 56px !important; }
.mobile-submenu-item { padding: 10px 14px !important; font-size: 13px !important; }
/* InitialReplication page - Small Mobile */ /* InitialReplication page - Small Mobile */
.replication-container { .replication-container {
@@ -417,102 +498,6 @@ html, body {
.mobile-mb-12 { margin-bottom: 12px !important; } .mobile-mb-12 { margin-bottom: 12px !important; }
} }
/* ── Mobile Menu Drawer ────────────── */
.mobile-menu-drawer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 300;
background: rgba(255,255,255,0.98);
backdrop-filter: blur(24px);
border-radius: 20px 20px 0 0;
box-shadow: 0 -8px 40px rgba(0,0,0,0.12);
max-height: 70vh;
display: flex;
flex-direction: column;
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.mobile-menu-drawer.open {
transform: translateY(0);
}
.mobile-menu-drawer .drawer-handle {
display: flex;
justify-content: center;
padding: 12px 0 4px;
}
.mobile-menu-drawer .drawer-handle span {
width: 36px;
height: 4px;
background: #cbd5e1;
border-radius: 2px;
}
.mobile-menu-drawer .drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 20px 12px;
border-bottom: 1px solid #f1f5f9;
}
.mobile-menu-drawer .drawer-title {
font-size: 15px;
font-weight: 600;
color: #1e293b;
}
.mobile-menu-drawer .drawer-body {
flex: 1;
overflow-y: auto;
padding: 8px 12px 20px;
}
.mobile-menu-drawer .drawer-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 12px;
font-size: 15px;
color: #334155;
cursor: pointer;
transition: background 0.15s;
}
.mobile-menu-drawer .drawer-item:active {
background: #f1f5f9;
}
.mobile-menu-drawer .drawer-item .drawer-icon {
font-size: 18px;
color: #6366f1;
width: 24px;
text-align: center;
}
.mobile-menu-drawer .drawer-back {
display: flex;
align-items: center;
gap: 4px;
color: #6366f1;
font-size: 14px;
cursor: pointer;
padding: 4px 8px;
border-radius: 8px;
}
.mobile-menu-drawer .drawer-back:active {
background: rgba(99,102,241,0.08);
}
.mobile-menu-overlay {
position: fixed;
inset: 0;
z-index: 299;
background: rgba(15, 23, 42, 0.4);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s;
}
.mobile-menu-overlay.open {
opacity: 1;
pointer-events: auto;
}
/* ── Tablet Breakpoint (769px to 1024px) ────────────── */ /* ── Tablet Breakpoint (769px to 1024px) ────────────── */
@media (min-width: 769px) and (max-width: 1024px) { @media (min-width: 769px) and (max-width: 1024px) {
/* /*
+3 -3
View File
@@ -313,7 +313,7 @@ const LoginPage: React.FC = () => {
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
{siteLogo ? ( {siteLogo ? (
<img src={siteLogo} alt="logo" style={{ width: 48, height: 48, borderRadius: 12, objectFit: 'contain' }} /> <img src={siteLogo} alt="logo" className="login-logo" style={{ width: 48, height: 48, borderRadius: 12, objectFit: 'contain' }} />
) : ( ) : (
<div style={{ <div style={{
width: 48, height: 48, borderRadius: 12, width: 48, height: 48, borderRadius: 12,
@@ -324,7 +324,7 @@ const LoginPage: React.FC = () => {
<ThunderboltOutlined style={{ fontSize: 24, color: '#fff' }} /> <ThunderboltOutlined style={{ fontSize: 24, color: '#fff' }} />
</div> </div>
)} )}
<span style={{ color: '#1e293b', fontSize: 24, fontWeight: 800, letterSpacing: -0.5 }}> <span className="login-brand-name" style={{ color: '#1e293b', fontSize: 24, fontWeight: 800, letterSpacing: -0.5 }}>
{siteName} {siteName}
</span> </span>
</div> </div>
@@ -371,7 +371,7 @@ const LoginPage: React.FC = () => {
{/* Right side - login/register form */} {/* Right side - login/register form */}
<div className="login-right-section"> <div className="login-right-section">
<Card style={{ <Card className="login-card" style={{
width: '100%', maxWidth: 400, borderRadius: 16, width: '100%', maxWidth: 400, borderRadius: 16,
boxShadow: '0 12px 40px rgba(0,0,0,0.08)', boxShadow: '0 12px 40px rgba(0,0,0,0.08)',
border: '1px solid #e2e8f0', background: '#fff', border: '1px solid #e2e8f0', background: '#fff',