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 type="module" crossorigin src="/assets/index-CW4DG5sI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hHWRoeqc.css">
<script type="module" crossorigin src="/assets/index-tvhdMiao.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D0-PljDg.css">
</head>
<body>
<div id="root"></div>
+32 -7
View File
@@ -241,18 +241,43 @@
@media (max-width: 480px) {
.login-left-section {
padding: 20px 16px 12px !important;
padding: 20px 12px 12px;
text-align: center;
}
.login-left-section > div > div:first-of-type {
justify-content: center;
}
.login-right-section {
padding: 12px 16px 24px !important;
padding: 12px 12px 24px;
align-items: flex-start;
}
.login-right-section .ant-card {
.login-container {
background-attachment: scroll;
background-position: center top;
}
.login-card {
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 [rechargeOptions, setRechargeOptions] = useState<any[]>([]);
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 [mobileExpandedParent, setMobileExpandedParent] = useState<MenuConfig | null>(null);
const [siteName, setSiteName] = useState(() => {
const cached = localStorage.getItem('siteInfo');
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 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 = [
{ key: 'profile', icon: <UserOutlined />, label: `账号: ${user?.username}`, 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'); }
};
// 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 () => {
try {
await pwdForm.validateFields();
@@ -493,23 +480,7 @@ const AppLayout: React.FC = () => {
{/* Menu */}
<div style={{ flex: 1, padding: '8px 8px', overflow: 'auto' }}>
{(() => {
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;
});
// childMap, topLevelItems are computed at component level above
const items: React.ReactNode[] = [];
const renderMenuItem = (item: MenuConfig, depth: number = 0) => {
@@ -742,69 +713,77 @@ const AppLayout: React.FC = () => {
</div>
</div>
{/* Mobile Bottom Nav */}
<div className="mobile-bottom-nav">
{menuItems.filter((item) => {
if ((item.menu_type ?? item.menuType) === 'group') return false;
// Only show top-level items (no parent)
if (item.parent_id || item.parentId) return false;
return true;
}).sort((a, b) => {
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>
{/* Mobile Bottom Nav - Two Level */}
<div className="mobile-bottom-nav-wrapper">
{/* Sub-menu panel (level-2) */}
{mobileExpandedParent && (
<div className="mobile-submenu-panel">
<div className="mobile-submenu-header" onClick={() => setMobileExpandedParent(null)}>
<LeftOutlined style={{ fontSize: 16, color: '#6366f1' }} />
<span className="mobile-submenu-title">{mobileExpandedParent.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>
<div className="mobile-submenu-list">
{(childMap[mobileExpandedParent.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;
}).map(child => (
<div
key={child.id}
className={`mobile-submenu-item ${child.path === selectedKey ? 'active' : ''}`}
onClick={() => {
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) */}
<div className={`mobile-menu-overlay ${mobileDrawerOpen ? 'open' : ''}`} onClick={closeMobileDrawer} />
<div className={`mobile-menu-drawer ${mobileDrawerOpen ? 'open' : ''}`}>
<div className="drawer-handle"><span /></div>
<div className="drawer-header">
{mobileDrawerParent && (
<div className="drawer-back" onClick={() => { setMobileDrawerParent(null); }}>
<LeftOutlined />
</div>
)}
<div className="drawer-title">{mobileDrawerParent?.label || '菜单'}</div>
<div style={{ width: 50 }} />
</div>
<div className="drawer-body">
{mobileDrawerChildren.map(item => (
<div
key={item.id}
className="drawer-item"
onClick={() => handleMobileChildClick(item)}
>
<span className="drawer-icon">{iconMap[item.icon] || <RightOutlined />}</span>
<span>{item.label}</span>
</div>
))}
{mobileDrawerChildren.length === 0 && (
<div style={{ textAlign: 'center', color: '#94a3b8', padding: '24px 0' }}>
</div>
)}
{/* Bottom nav (level-1) */}
<div className="mobile-bottom-nav">
{topLevelForNav.map((item) => {
const hasChildren = childMap[item.id] && childMap[item.id].length > 0;
const isActive = mobileExpandedParent?.id === item.id
|| (!mobileExpandedParent && item.path === selectedKey)
|| (!mobileExpandedParent && hasChildren && (childMap[item.id] || []).some(c => c.path === selectedKey));
return (
<div
key={item.id}
className={`nav-item ${isActive ? 'active' : ''}`}
onClick={() => {
if (hasChildren) {
setMobileExpandedParent(prev => prev?.id === item.id ? null : item);
} else if (item.path) {
navigate(item.path);
}
}}
>
<span className="nav-icon">{iconMap[item.icon] || <HomeOutlined />}</span>
<span>{item.label}</span>
{hasChildren && (
<span className="nav-expand-dot" style={{
width: 4, height: 4, borderRadius: '50%',
background: isActive ? '#6366f1' : '#cbd5e1',
}} />
)}
</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>
+82 -97
View File
@@ -213,9 +213,12 @@ html, body {
}
/* ── Mobile Bottom Nav ─────────────────── */
.mobile-bottom-nav {
.mobile-bottom-nav-wrapper {
display: none;
position: fixed; bottom: 0; left: 0; right: 0; z-index: 200;
}
.mobile-bottom-nav {
display: none;
height: 60px;
background: rgba(255,255,255,0.95);
backdrop-filter: blur(24px);
@@ -228,6 +231,7 @@ html, body {
cursor: pointer; transition: all 0.2s;
color: #8b8fa3; font-size: 9px; font-weight: 400;
letter-spacing: 0;
position: relative;
}
.mobile-bottom-nav .nav-item.active {
color: #08080c;
@@ -238,12 +242,87 @@ html, body {
.mobile-bottom-nav .nav-item.active .nav-icon {
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 ────────────── */
@media (max-width: 768px) {
.desktop-sidebar { display: none !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; }
.mobile-bottom-nav-wrapper { display: block !important; }
.mobile-bottom-nav { display: flex !important; }
/* Prevent horizontal overflow globally */
@@ -367,6 +446,8 @@ html, body {
.mobile-bottom-nav { height: 56px !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-submenu-panel { bottom: 56px !important; }
.mobile-submenu-item { padding: 10px 14px !important; font-size: 13px !important; }
/* InitialReplication page - Small Mobile */
.replication-container {
@@ -417,102 +498,6 @@ html, body {
.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) ────────────── */
@media (min-width: 769px) and (max-width: 1024px) {
/*
+3 -3
View File
@@ -313,7 +313,7 @@ const LoginPage: React.FC = () => {
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
{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={{
width: 48, height: 48, borderRadius: 12,
@@ -324,7 +324,7 @@ const LoginPage: React.FC = () => {
<ThunderboltOutlined style={{ fontSize: 24, color: '#fff' }} />
</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}
</span>
</div>
@@ -371,7 +371,7 @@ const LoginPage: React.FC = () => {
{/* Right side - login/register form */}
<div className="login-right-section">
<Card style={{
<Card className="login-card" style={{
width: '100%', maxWidth: 400, borderRadius: 16,
boxShadow: '0 12px 40px rgba(0,0,0,0.08)',
border: '1px solid #e2e8f0', background: '#fff',