mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 21:14:21 +08:00
feat: 优化手机设置页入口并新增子页面进入动画
This commit is contained in:
+86
-19
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user