feat: 支持移动端底部导航自定义与更多收纳

This commit is contained in:
JSR
2026-03-28 10:01:01 +08:00
parent 914eefa56a
commit 4af6157e0d
11 changed files with 685 additions and 197 deletions
+180 -43
View File
@@ -1,5 +1,16 @@
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd"
import { HomeOutlined, SettingOutlined } from "@ant-design/icons"
import {
HomeOutlined,
SettingOutlined,
UserOutlined,
HistoryOutlined,
SyncOutlined,
AppstoreAddOutlined,
ApartmentOutlined,
UnorderedListOutlined,
FileTextOutlined,
MoreOutlined,
} from "@ant-design/icons"
import { useEffect, useMemo, useRef, useState } from "react"
import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
import { useTranslation } from "react-i18next"
@@ -7,6 +18,13 @@ import { Sidebar } from "./components/Sidebar"
import { ContentArea } from "./components/ContentArea"
import { OOBE } from "./components/OOBE/OOBE"
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
import {
MOBILE_NAV_ITEMS,
MobileNavKey,
sanitizeMobileNavKeys,
} from "./shared/mobileNavigation"
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
function MainContent(): React.JSX.Element {
const { t } = useTranslation()
@@ -59,6 +77,10 @@ function MainContent(): React.JSX.Element {
const [authVisible, setAuthVisible] = useState(false)
const [authPassword, setAuthPassword] = useState("")
const [authLoading, setAuthLoading] = useState(false)
const [mobileBottomNavItems, setMobileBottomNavItems] = useState<MobileNavKey[]>(
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
)
const [moreNavVisible, setMoreNavVisible] = useState(false)
const [isPortraitMode] = useState(defaultPortraitMode)
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
@@ -107,11 +129,49 @@ function MainContent(): React.JSX.Element {
setHasAnyPassword(anyPwd)
if (anyPwd && authRes.data.permission === "view") setAuthVisible(true)
}
const settingsRes = await (window as any).api.getAllSettings()
if (settingsRes?.success && settingsRes.data) {
setMobileBottomNavItems(
sanitizeMobileNavKeys(
settingsRes.data.mobile_bottom_nav_items,
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
)
)
}
}
loadAuthAndSettings()
}, [])
useEffect(() => {
const api = (window as any).api
if (!api || typeof api.onSettingChanged !== "function") return
let disposed = false
let unlisten: (() => void) | null = null
api
.onSettingChanged((change: { key?: string; value?: unknown }) => {
if (change?.key !== "mobile_bottom_nav_items") return
setMobileBottomNavItems(
sanitizeMobileNavKeys(change.value, DEFAULT_MOBILE_BOTTOM_NAV_ITEMS)
)
})
.then((fn: () => void) => {
if (disposed) {
fn()
return
}
unlisten = fn
})
.catch(() => void 0)
return () => {
disposed = true
if (unlisten) unlisten()
}
}, [])
useEffect(() => {
const api = (window as any).api
if (!api || !isIosDevice) return
@@ -309,6 +369,7 @@ function MainContent(): React.JSX.Element {
const onMenuChange = (v: string) => {
const key = String(v)
setMoreNavVisible(false)
if (immersiveMode && key !== "home") return
if (key === "home") navigate("/")
if (key === "students") navigate("/students")
@@ -355,6 +416,45 @@ function MainContent(): React.JSX.Element {
const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9"
const isMobileDevice = isIosDevice || isAndroidDevice
const showMobileBottomNav = isPortraitMode && !immersiveMode
const mobileBottomNavIconMap: Record<MobileNavKey, React.ReactNode> = {
home: <HomeOutlined style={{ fontSize: "18px" }} />,
students: <UserOutlined style={{ fontSize: "18px" }} />,
score: <HistoryOutlined style={{ fontSize: "18px" }} />,
"auto-score": <SyncOutlined style={{ fontSize: "18px" }} />,
"reward-settings": <AppstoreAddOutlined style={{ fontSize: "18px" }} />,
boards: <ApartmentOutlined style={{ fontSize: "18px" }} />,
leaderboard: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
settlements: <FileTextOutlined style={{ fontSize: "18px" }} />,
reasons: <UnorderedListOutlined style={{ fontSize: "18px" }} />,
settings: <SettingOutlined style={{ fontSize: "18px" }} />,
}
const mobileBottomVisibleItems = useMemo(() => {
const preferredKeys = sanitizeMobileNavKeys(
mobileBottomNavItems,
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
).slice()
for (const baseKey of ["home", "settings"] as const) {
if (!preferredKeys.includes(baseKey)) {
preferredKeys.push(baseKey)
}
}
return preferredKeys
.map((key) => MOBILE_NAV_ITEMS.find((item) => item.key === key))
.filter((item): item is (typeof MOBILE_NAV_ITEMS)[number] => Boolean(item))
.filter((item) => !(item.adminOnly && permission !== "admin"))
}, [mobileBottomNavItems, permission])
const mobileBottomPrimaryItems = useMemo(
() => mobileBottomVisibleItems.slice(0, 4),
[mobileBottomVisibleItems]
)
const mobileBottomOverflowItems = useMemo(
() => mobileBottomVisibleItems.slice(4),
[mobileBottomVisibleItems]
)
const isMoreActive = mobileBottomOverflowItems.some((item) => item.key === activeMenu)
return (
<ConfigProvider
@@ -423,54 +523,91 @@ function MainContent(): React.JSX.Element {
}}
>
<div style={{ display: "flex", height: "60px" }}>
<button
type="button"
onClick={() => onMenuChange("home")}
style={{
flex: 1,
border: "none",
background: "transparent",
color:
activeMenu === "home"
{mobileBottomPrimaryItems.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onMenuChange(item.key)}
style={{
flex: 1,
border: "none",
background: "transparent",
color:
activeMenu === item.key
? "var(--ant-color-primary)"
: "var(--ss-text-secondary, var(--ss-text-main))",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "2px",
fontSize: "12px",
}}
>
{mobileBottomNavIconMap[item.key]}
<span>{t(item.labelKey)}</span>
</button>
))}
{mobileBottomOverflowItems.length > 0 && (
<button
type="button"
onClick={() => setMoreNavVisible(true)}
style={{
flex: 1,
border: "none",
background: "transparent",
color: isMoreActive
? "var(--ant-color-primary)"
: "var(--ss-text-secondary, var(--ss-text-main))",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "2px",
fontSize: "12px",
}}
>
<HomeOutlined style={{ fontSize: "18px" }} />
<span>{t("sidebar.home")}</span>
</button>
<button
type="button"
onClick={() => onMenuChange("settings")}
style={{
flex: 1,
border: "none",
background: "transparent",
color:
activeMenu === "home"
? "var(--ss-text-secondary, var(--ss-text-main))"
: "var(--ant-color-primary)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "2px",
fontSize: "12px",
}}
>
<SettingOutlined style={{ fontSize: "18px" }} />
<span>{t("sidebar.settings")}</span>
</button>
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "2px",
fontSize: "12px",
}}
>
<MoreOutlined style={{ fontSize: "18px" }} />
<span>{t("common.more", "更多")}</span>
</button>
)}
</div>
</div>
)}
<Modal
title={t("common.more", "更多")}
open={moreNavVisible}
onCancel={() => setMoreNavVisible(false)}
footer={null}
width={360}
>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{mobileBottomOverflowItems.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onMenuChange(item.key)}
style={{
border: "1px solid var(--ss-border-color)",
borderRadius: "10px",
background: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
height: "44px",
padding: "0 12px",
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "14px",
}}
>
{mobileBottomNavIconMap[item.key]}
<span>{t(item.labelKey)}</span>
</button>
))}
</div>
</Modal>
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
<Modal