feat: 优化手机设置页入口并新增子页面进入动画

This commit is contained in:
JSR
2026-03-28 09:23:49 +08:00
parent 76341d41c3
commit 56c2a4643c
4 changed files with 269 additions and 55 deletions
+86 -19
View File
@@ -1,4 +1,5 @@
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd" import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd"
import { HomeOutlined, SettingOutlined } from "@ant-design/icons"
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom" import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -352,6 +353,7 @@ function MainContent(): React.JSX.Element {
const isDark = currentTheme?.mode === "dark" const isDark = currentTheme?.mode === "dark"
const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9" const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9"
const showMobileBottomNav = isPortraitMode && !immersiveMode
return ( return (
<ConfigProvider <ConfigProvider
@@ -365,25 +367,27 @@ function MainContent(): React.JSX.Element {
}} }}
> >
{contextHolder} {contextHolder}
<Layout style={{ height: "100%", flexDirection: "row", overflow: "hidden" }}> <Layout style={{ height: "100%", flexDirection: "row", overflow: "hidden", position: "relative" }}>
<div {!isPortraitMode && (
className={`ss-immersive-sidebar ${immersiveMode ? "is-hidden" : "is-visible"}`} <div
style={ className={`ss-immersive-sidebar ${immersiveMode ? "is-hidden" : "is-visible"}`}
{ style={
"--ss-sidebar-width": `${sidebarCollapsed ? 64 : 200}px`, {
} as React.CSSProperties "--ss-sidebar-width": `${sidebarCollapsed ? 64 : 200}px`,
} } as React.CSSProperties
> }
<Sidebar >
activeMenu={activeMenu} <Sidebar
permission={permission} activeMenu={activeMenu}
onMenuChange={onMenuChange} permission={permission}
collapsed={sidebarCollapsed} onMenuChange={onMenuChange}
floatingExpand={isPortraitMode} collapsed={sidebarCollapsed}
floatingExpanded={floatingSidebarExpanded} floatingExpand={isPortraitMode}
onFloatingExpandedChange={setFloatingSidebarExpanded} floatingExpanded={floatingSidebarExpanded}
/> onFloatingExpandedChange={setFloatingSidebarExpanded}
</div> />
</div>
)}
<ContentArea <ContentArea
permission={permission} permission={permission}
hasAnyPassword={hasAnyPassword} hasAnyPassword={hasAnyPassword}
@@ -398,7 +402,70 @@ function MainContent(): React.JSX.Element {
immersiveMode={immersiveMode} immersiveMode={immersiveMode}
isHomePage={activeMenu === "home"} isHomePage={activeMenu === "home"}
onToggleImmersiveMode={toggleImmersiveMode} onToggleImmersiveMode={toggleImmersiveMode}
showSidebarToggle={!isPortraitMode}
bottomInset={showMobileBottomNav ? 84 : 0}
/> />
{showMobileBottomNav && (
<div
style={{
position: "fixed",
left: 0,
right: 0,
bottom: 0,
background: "var(--ss-header-bg)",
borderTop: "1px solid var(--ss-border-color)",
zIndex: 1400,
paddingBottom: "env(safe-area-inset-bottom, 0px)",
}}
>
<div style={{ display: "flex", height: "60px" }}>
<button
type="button"
onClick={() => onMenuChange("home")}
style={{
flex: 1,
border: "none",
background: "transparent",
color:
activeMenu === "home"
? "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>
</div>
</div>
)}
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} /> <OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
+69 -4
View File
@@ -270,9 +270,74 @@ html.platform-macos #root {
pointer-events: none; pointer-events: none;
} }
@media (prefers-reduced-motion: reduce) { .ss-settings-mobile-nav-list {
.ss-immersive-sidebar, display: flex;
.ss-immersive-toolbar { flex-direction: column;
transition: none !important; }
.ss-settings-mobile-nav-item.ant-btn {
min-height: 48px;
width: 100%;
border-radius: 0;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
color: var(--ss-text-main);
border-bottom: 1px solid var(--ss-divider, rgba(5, 5, 5, 0.06));
}
.ss-settings-mobile-nav-item.ant-btn:last-child {
border-bottom: none;
}
.ss-settings-mobile-nav-item.ant-btn:hover,
.ss-settings-mobile-nav-item.ant-btn:focus-visible {
background: var(--ss-item-hover, rgba(0, 0, 0, 0.04));
}
.ss-settings-mobile-nav-item.ant-btn.is-active {
color: var(--ant-color-primary, #1677ff);
background: color-mix(in srgb, var(--ant-color-primary, #1677ff) 10%, transparent);
}
.ss-settings-mobile-nav-item.ant-btn[disabled] {
color: var(--ss-text-secondary);
opacity: 0.65;
}
.ss-settings-mobile-nav-item-label {
font-size: 15px;
font-weight: 500;
}
.ss-settings-mobile-nav-item-arrow {
font-size: 12px;
}
.ss-route-page {
min-height: 100%;
}
.ss-route-page.is-subpage-enter {
animation: ss-subpage-enter 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes ss-subpage-enter {
from {
opacity: 0;
transform: translate3d(20px, 0, 0);
}
to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
}
@media (prefers-reduced-motion: reduce) {
.ss-immersive-sidebar,
.ss-immersive-toolbar,
.ss-route-page.is-subpage-enter {
transition: none !important;
animation: none !important;
} }
} }
+58 -31
View File
@@ -6,7 +6,7 @@ import {
FullscreenOutlined, FullscreenOutlined,
FullscreenExitOutlined, FullscreenExitOutlined,
} from "@ant-design/icons" } from "@ant-design/icons"
import { Routes, Route, Navigate } from "react-router-dom" import { Routes, Route, Navigate, useLocation } from "react-router-dom"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { WindowControls } from "./WindowControls" import { WindowControls } from "./WindowControls"
import appLogo from "../assets/logoHD.svg" import appLogo from "../assets/logoHD.svg"
@@ -67,6 +67,8 @@ interface ContentAreaProps {
immersiveMode: boolean immersiveMode: boolean
isHomePage: boolean isHomePage: boolean
onToggleImmersiveMode: () => void onToggleImmersiveMode: () => void
showSidebarToggle?: boolean
bottomInset?: number
} }
export function ContentArea({ export function ContentArea({
@@ -83,8 +85,13 @@ export function ContentArea({
immersiveMode, immersiveMode,
isHomePage, isHomePage,
onToggleImmersiveMode, onToggleImmersiveMode,
showSidebarToggle = true,
bottomInset = 0,
}: ContentAreaProps): React.JSX.Element { }: ContentAreaProps): React.JSX.Element {
const { t } = useTranslation() const { t } = useTranslation()
const location = useLocation()
const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home")
const shouldAnimateSubPage = isPortraitMode && isSubPage
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -156,7 +163,7 @@ export function ContentArea({
} as React.CSSProperties } as React.CSSProperties
} }
> >
{!immersiveMode && ( {!immersiveMode && showSidebarToggle && (
<Button <Button
type="text" type="text"
size="small" size="small"
@@ -246,7 +253,14 @@ export function ContentArea({
{showWindowControls && <WindowControls />} {showWindowControls && <WindowControls />}
</div> </div>
<Content style={{ flex: 1, overflowY: "auto", overflowX: "hidden" }}> <Content
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
paddingBottom: bottomInset ? `${bottomInset}px` : 0,
}}
>
<Suspense <Suspense
fallback={ fallback={
<div <div
@@ -263,34 +277,47 @@ export function ContentArea({
</div> </div>
} }
> >
<Routes> <div
<Route key={location.pathname}
path="/" className={`ss-route-page${shouldAnimateSubPage ? " is-subpage-enter" : ""}`}
element={ >
<Home <Routes>
canEdit={permission === "admin" || permission === "points"} <Route
isPortraitMode={isPortraitMode} path="/"
immersiveMode={immersiveMode} element={
/> <Home
} canEdit={permission === "admin" || permission === "points"}
/> isPortraitMode={isPortraitMode}
<Route path="/students" element={<StudentManager canEdit={permission === "admin"} />} /> immersiveMode={immersiveMode}
<Route />
path="/score" }
element={<ScoreManager canEdit={permission === "admin" || permission === "points"} />} />
/> <Route
<Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} /> path="/students"
<Route path="/leaderboard" element={<Leaderboard />} /> element={<StudentManager canEdit={permission === "admin"} />}
<Route path="/settlements" element={<SettlementHistory />} /> />
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} /> <Route
<Route path="/auto-score" element={<AutoScoreManager />} /> path="/score"
<Route element={
path="/reward-settings" <ScoreManager canEdit={permission === "admin" || permission === "points"} />
element={<RewardSettings canEdit={permission === "admin"} />} }
/> />
<Route path="/settings" element={<Settings permission={permission} />} /> <Route path="/boards" element={<BoardManager canManage={permission === "admin"} />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="/leaderboard" element={<Leaderboard />} />
</Routes> <Route path="/settlements" element={<SettlementHistory />} />
<Route path="/reasons" element={<ReasonManager canEdit={permission === "admin"} />} />
<Route path="/auto-score" element={<AutoScoreManager />} />
<Route
path="/reward-settings"
element={<RewardSettings canEdit={permission === "admin"} />}
/>
<Route
path="/settings"
element={<Settings permission={permission} mobileNavigationEnabled={isPortraitMode} />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</Suspense> </Suspense>
</Content> </Content>
</Layout> </Layout>
+56 -1
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react" import React, { useEffect, useMemo, useRef, useState } from "react"
import { RightOutlined } from "@ant-design/icons"
import { import {
Tabs, Tabs,
Card, Card,
@@ -17,6 +18,7 @@ import { ThemeQuickSettings } from "./ThemeQuickSettings"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n" import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n"
import { useResponsive } from "../hooks/useResponsive" import { useResponsive } from "../hooks/useResponsive"
import { useLocation, useNavigate } from "react-router-dom"
type permissionLevel = "admin" | "points" | "view" type permissionLevel = "admin" | "points" | "view"
type appSettings = { type appSettings = {
@@ -46,8 +48,13 @@ const withTimeout = async (
} }
} }
export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission }) => { export const Settings: React.FC<{
permission: permissionLevel
mobileNavigationEnabled?: boolean
}> = ({ permission, mobileNavigationEnabled = false }) => {
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const breakpoint = useResponsive() const breakpoint = useResponsive()
const isMobile = breakpoint === "xs" || breakpoint === "sm" const isMobile = breakpoint === "xs" || breakpoint === "sm"
const [activeTab, setActiveTab] = useState("appearance") const [activeTab, setActiveTab] = useState("appearance")
@@ -132,6 +139,25 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
) )
}, [permission, t]) }, [permission, t])
const mobilePageItems = useMemo(
() => [
{ key: "students", path: "/students", label: t("sidebar.students"), disabled: !canAdmin },
{ key: "score", path: "/score", label: t("sidebar.score"), disabled: false },
{ key: "auto-score", path: "/auto-score", label: t("sidebar.autoScore"), disabled: false },
{
key: "reward-settings",
path: "/reward-settings",
label: t("sidebar.rewardSettings"),
disabled: !canAdmin,
},
{ key: "boards", path: "/boards", label: t("sidebar.boards"), disabled: false },
{ key: "leaderboard", path: "/leaderboard", label: t("sidebar.leaderboard"), disabled: false },
{ key: "settlements", path: "/settlements", label: t("sidebar.settlements"), disabled: false },
{ key: "reasons", path: "/reasons", label: t("sidebar.reasons"), disabled: !canAdmin },
],
[canAdmin, t]
)
const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => {
window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } }))
} }
@@ -1277,6 +1303,35 @@ export const Settings: React.FC<{ permission: permissionLevel }> = ({ permission
{permissionTag} {permissionTag}
</div> </div>
{mobileNavigationEnabled && isMobile && (
<Card
title={t("settings.mobile.navigationTitle", "页面入口")}
bodyStyle={{ padding: 0 }}
style={{
marginBottom: "16px",
backgroundColor: "var(--ss-card-bg)",
color: "var(--ss-text-main)",
}}
>
<div className="ss-settings-mobile-nav-list">
{mobilePageItems.map((item) => (
<Button
key={item.key}
disabled={item.disabled}
type="text"
className={`ss-settings-mobile-nav-item${
location.pathname.startsWith(item.path) ? " is-active" : ""
}`}
onClick={() => navigate(item.path)}
>
<span className="ss-settings-mobile-nav-item-label">{item.label}</span>
<RightOutlined className="ss-settings-mobile-nav-item-arrow" />
</Button>
))}
</div>
</Card>
)}
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} /> <Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
<Modal <Modal