From f3ee0c52020ba10d7be9e6ed3a349e7e10a76580 Mon Sep 17 00:00:00 2001 From: JSR Date: Sat, 28 Mar 2026 10:41:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E5=BA=95=E6=A0=8F=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E4=B8=8E=E6=9B=B4=E5=A4=9A=E5=88=86=E7=BB=84=E5=BC=82=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 357 ++++++++++++++++++++++++++++----- src/components/ContentArea.tsx | 2 +- src/components/Settings.tsx | 109 +--------- src/i18n/locales/en-US.json | 8 +- src/i18n/locales/zh-CN.json | 8 +- 5 files changed, 320 insertions(+), 164 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index da93423..d9aff38 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd" +import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme, Drawer } from "antd" import { HomeOutlined, SettingOutlined, @@ -10,6 +10,9 @@ import { UnorderedListOutlined, FileTextOutlined, MoreOutlined, + UpOutlined, + DownOutlined, + HolderOutlined, } from "@ant-design/icons" import { useEffect, useMemo, useRef, useState } from "react" import { HashRouter, useLocation, useNavigate, Routes, Route } from "react-router-dom" @@ -25,6 +28,7 @@ import { } from "./shared/mobileNavigation" const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key) +const DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS: MobileNavKey[] = DEFAULT_MOBILE_BOTTOM_NAV_ITEMS.slice(0, 4) function MainContent(): React.JSX.Element { const { t } = useTranslation() @@ -35,6 +39,13 @@ function MainContent(): React.JSX.Element { const { isIosDevice, isAndroidDevice, defaultPortraitMode } = useMemo(getMobileDeviceInfo, []) const [immersiveMode, setImmersiveMode] = useState(false) + const normalizeStoredBottomKeys = (raw: unknown): MobileNavKey[] => { + if (Array.isArray(raw)) { + return sanitizeMobileNavKeys(raw, []).slice(0, 4) + } + return DEFAULT_MOBILE_BOTTOM_PRIMARY_KEYS + } + useEffect(() => { const api = (window as any).api if (!api) return @@ -81,6 +92,14 @@ function MainContent(): React.JSX.Element { DEFAULT_MOBILE_BOTTOM_NAV_ITEMS ) const [moreNavVisible, setMoreNavVisible] = useState(false) + const [editingNav, setEditingNav] = useState(false) + const [editingBottomNavKeys, setEditingBottomNavKeys] = useState([]) + const [editingMoreNavKeys, setEditingMoreNavKeys] = useState([]) + const [draggingNavKey, setDraggingNavKey] = useState(null) + const [draggingFromList, setDraggingFromList] = useState<"bottom" | "more" | null>(null) + const [dragOverSlot, setDragOverSlot] = useState<{ list: "bottom" | "more"; index: number } | null>( + null + ) const [isPortraitMode] = useState(defaultPortraitMode) const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode) const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false) @@ -131,12 +150,7 @@ function MainContent(): React.JSX.Element { } 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 - ) - ) + setMobileBottomNavItems(normalizeStoredBottomKeys(settingsRes.data.mobile_bottom_nav_items)) } } @@ -153,9 +167,7 @@ function MainContent(): React.JSX.Element { api .onSettingChanged((change: { key?: string; value?: unknown }) => { if (change?.key !== "mobile_bottom_nav_items") return - setMobileBottomNavItems( - sanitizeMobileNavKeys(change.value, DEFAULT_MOBILE_BOTTOM_NAV_ITEMS) - ) + setMobileBottomNavItems(normalizeStoredBottomKeys(change.value)) }) .then((fn: () => void) => { if (disposed) { @@ -370,6 +382,7 @@ function MainContent(): React.JSX.Element { const onMenuChange = (v: string) => { const key = String(v) setMoreNavVisible(false) + setEditingNav(false) if (immersiveMode && key !== "home") return if (key === "home") navigate("/") if (key === "students") navigate("/students") @@ -429,33 +442,96 @@ function MainContent(): React.JSX.Element { settings: , } - 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 mobileNavAvailableItems = useMemo( + () => MOBILE_NAV_ITEMS.filter((item) => !(item.adminOnly && permission !== "admin")), + [permission] + ) + const mobileBottomSelectedKeys = useMemo(() => { + const availableKeySet = new Set(mobileNavAvailableItems.map((item) => item.key)) + return sanitizeMobileNavKeys(mobileBottomNavItems, []) + .filter((key) => availableKeySet.has(key)) + .slice(0, 4) + }, [mobileBottomNavItems, mobileNavAvailableItems]) const mobileBottomPrimaryItems = useMemo( - () => mobileBottomVisibleItems.slice(0, 4), - [mobileBottomVisibleItems] + () => + mobileBottomSelectedKeys + .map((key) => mobileNavAvailableItems.find((item) => item.key === key)) + .filter((item): item is (typeof MOBILE_NAV_ITEMS)[number] => Boolean(item)), + [mobileBottomSelectedKeys, mobileNavAvailableItems] ) const mobileBottomOverflowItems = useMemo( - () => mobileBottomVisibleItems.slice(4), - [mobileBottomVisibleItems] + () => mobileNavAvailableItems.filter((item) => !mobileBottomSelectedKeys.includes(item.key)), + [mobileNavAvailableItems, mobileBottomSelectedKeys] ) const isMoreActive = mobileBottomOverflowItems.some((item) => item.key === activeMenu) + const openEditNav = () => { + const savedKeys = mobileBottomSelectedKeys + const missingKeys = mobileNavAvailableItems + .map((item) => item.key) + .filter((key) => !savedKeys.includes(key)) + setEditingBottomNavKeys(savedKeys) + setEditingMoreNavKeys(missingKeys) + setEditingNav(true) + setDraggingNavKey(null) + setDraggingFromList(null) + setDragOverSlot(null) + } + + const dropPlaceholder = ( +
+ ) + + const persistMobileBottomKeys = async (nextKeys: MobileNavKey[]) => { + const api = (window as any).api + if (!api) return + const next = sanitizeMobileNavKeys(nextKeys, []) + const res = await api.setSetting("mobile_bottom_nav_items", next) + if (res.success) { + setMobileBottomNavItems(next) + } else { + messageApi.error(res.message || t("settings.general.saveFailed")) + } + } + + const handleDropToList = (targetList: "bottom" | "more", targetIndex: number) => { + if (!draggingNavKey || !draggingFromList) return + const bottom = editingBottomNavKeys.slice() + const more = editingMoreNavKeys.slice() + + const source = draggingFromList === "bottom" ? bottom : more + const sourceIndex = source.indexOf(draggingNavKey) + if (sourceIndex < 0) return + source.splice(sourceIndex, 1) + + if (targetList === "bottom" && draggingFromList === "more" && bottom.length >= 4) { + setDraggingNavKey(null) + setDraggingFromList(null) + setDragOverSlot(null) + messageApi.warning(t("settings.mobile.bottomMaxHint", "底栏最多 4 个")) + return + } + + const target = targetList === "bottom" ? bottom : more + const clampedIndex = Math.max(0, Math.min(targetIndex, target.length)) + target.splice(clampedIndex, 0, draggingNavKey) + + setEditingBottomNavKeys(bottom) + setEditingMoreNavKeys(more) + void persistMobileBottomKeys(bottom) + setDraggingNavKey(null) + setDraggingFromList(null) + setDragOverSlot(null) + } + return ( )} - setMoreNavVisible(false)} - footer={null} - width={360} - > -
- {mobileBottomOverflowItems.map((item) => ( + onClose={() => { + setMoreNavVisible(false) + setEditingNav(false) + setDraggingNavKey(null) + setDraggingFromList(null) + setDragOverSlot(null) + }} + height={editingNav ? "calc(100vh - env(safe-area-inset-top, 0px))" : "46vh"} + styles={{ + content: { + borderTopLeftRadius: editingNav ? 0 : "14px", + borderTopRightRadius: editingNav ? 0 : "14px", + background: "var(--ss-card-bg)", + }, + header: { + borderBottom: "1px solid var(--ss-border-color)", + padding: "12px 16px", + }, + body: { + padding: "12px 16px 20px", + display: "flex", + flexDirection: "column", + minHeight: 0, + }, + }} + extra={ + permission === "admin" && + (editingNav ? ( - ))} -
-
+ ) : ( + + )) + } + > + {editingNav ? ( +
+
+ {t( + "settings.mobile.dragHint", + "拖动可调整顺序。前 4 项显示在底栏,其余显示在“更多”。" + )} +
+ {[ + { + list: "bottom" as const, + title: t("settings.mobile.bottomSection", "底栏(最多 4 个)"), + keys: editingBottomNavKeys, + }, + { + list: "more" as const, + title: t("settings.mobile.moreSection", "更多"), + keys: editingMoreNavKeys, + }, + ].map((group) => ( +
+
+ {group.title} +
+
+ {Array.from({ length: group.keys.length + 1 }, (_, i) => { + const key = group.keys[i] + const item = key ? MOBILE_NAV_ITEMS.find((it) => it.key === key) : null + return ( +
+
setDragOverSlot({ list: group.list, index: i })} + onDragOver={(e) => { + e.preventDefault() + setDragOverSlot({ list: group.list, index: i }) + }} + onDrop={() => handleDropToList(group.list, i)} + > + {dragOverSlot?.list === group.list && dragOverSlot?.index === i ? ( + dropPlaceholder + ) : ( +
+ )} +
+ {key && item && ( + + )} +
+ ) + })} +
+
+ ))} +
+ ) : ( +
+ {mobileBottomOverflowItems.map((item) => ( + + ))} +
+ )} + setWizardVisible(false)} /> diff --git a/src/components/ContentArea.tsx b/src/components/ContentArea.tsx index a392750..152c34b 100644 --- a/src/components/ContentArea.tsx +++ b/src/components/ContentArea.tsx @@ -380,7 +380,7 @@ export function ContentArea({ /> } + element={} /> } /> diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index a8fefba..0d7c71d 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo, useRef, useState } from "react" -import { RightOutlined } from "@ant-design/icons" import { Tabs, Card, @@ -18,12 +17,6 @@ import { ThemeQuickSettings } from "./ThemeQuickSettings" import { useTranslation } from "react-i18next" import { changeLanguage, getCurrentLanguage, languageOptions, AppLanguage } from "../i18n" import { useResponsive } from "../hooks/useResponsive" -import { useLocation, useNavigate } from "react-router-dom" -import { - MOBILE_NAV_ITEMS, - MobileNavKey, - sanitizeMobileNavKeys, -} from "../shared/mobileNavigation" type permissionLevel = "admin" | "points" | "view" type appSettings = { @@ -33,11 +26,7 @@ type appSettings = { search_keyboard_layout?: "t9" | "qwerty26" disable_search_keyboard?: boolean auto_score_enabled?: boolean - mobile_bottom_nav_items?: MobileNavKey[] } - -const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key) - const withTimeout = async ( promise: Promise, ms: number, @@ -58,11 +47,8 @@ const withTimeout = async ( export const Settings: React.FC<{ permission: permissionLevel - mobileNavigationEnabled?: boolean -}> = ({ permission, mobileNavigationEnabled = false }) => { +}> = ({ permission }) => { const { t } = useTranslation() - const navigate = useNavigate() - const location = useLocation() const breakpoint = useResponsive() const isMobile = breakpoint === "xs" || breakpoint === "sm" const [activeTab, setActiveTab] = useState("appearance") @@ -73,7 +59,6 @@ export const Settings: React.FC<{ window_zoom: "1.0", search_keyboard_layout: "qwerty26", disable_search_keyboard: false, - mobile_bottom_nav_items: DEFAULT_MOBILE_BOTTOM_NAV_ITEMS, }) const [securityStatus, setSecurityStatus] = useState<{ @@ -148,19 +133,6 @@ export const Settings: React.FC<{ ) }, [permission, t]) - const mobilePageItems = useMemo( - () => - MOBILE_NAV_ITEMS.filter((item) => item.key !== "home" && item.key !== "settings").map( - (item) => ({ - key: item.key, - path: item.path, - label: t(item.labelKey), - disabled: Boolean(item.adminOnly) && !canAdmin, - }) - ), - [canAdmin, t] - ) - const emitDataUpdated = (category: "events" | "students" | "reasons" | "all") => { window.dispatchEvent(new CustomEvent("ss:data-updated", { detail: { category } })) } @@ -184,13 +156,7 @@ export const Settings: React.FC<{ if (!(window as any).api) return const res = await (window as any).api.getAllSettings() if (res.success && res.data) { - setSettings({ - ...res.data, - mobile_bottom_nav_items: sanitizeMobileNavKeys( - res.data.mobile_bottom_nav_items, - DEFAULT_MOBILE_BOTTOM_NAV_ITEMS - ), - }) + setSettings(res.data) setPgConnectionString(res.data.pg_connection_string || "") setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" }) } @@ -220,15 +186,6 @@ export const Settings: React.FC<{ return { ...prev, disable_search_keyboard: Boolean(change.value) } if (change?.key === "auto_score_enabled") return { ...prev, auto_score_enabled: change.value } - if (change?.key === "mobile_bottom_nav_items") { - return { - ...prev, - mobile_bottom_nav_items: sanitizeMobileNavKeys( - change.value, - DEFAULT_MOBILE_BOTTOM_NAV_ITEMS - ), - } - } return prev }) }) @@ -637,18 +594,6 @@ export const Settings: React.FC<{ }) } - const handleMobileBottomNavChange = async (value: string[]) => { - if (!(window as any).api) return - const next = sanitizeMobileNavKeys(value, DEFAULT_MOBILE_BOTTOM_NAV_ITEMS) - const res = await (window as any).api.setSetting("mobile_bottom_nav_items", next) - if (res.success) { - setSettings((prev) => ({ ...prev, mobile_bottom_nav_items: next })) - messageApi.success(t("settings.general.saved")) - } else { - messageApi.error(res.message || t("settings.general.saveFailed")) - } - } - const tabItems = [ { key: "appearance", @@ -776,27 +721,6 @@ export const Settings: React.FC<{
- {mobileNavigationEnabled && ( - -