mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 12:34:22 +08:00
Merge branch 'main' of https://github.com/SECTL/SecScore
This commit is contained in:
+303
-54
@@ -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 {
|
import {
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
|
UpOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
HolderOutlined,
|
||||||
} from "@ant-design/icons"
|
} 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"
|
||||||
@@ -25,6 +28,7 @@ import {
|
|||||||
} from "./shared/mobileNavigation"
|
} from "./shared/mobileNavigation"
|
||||||
|
|
||||||
const DEFAULT_MOBILE_BOTTOM_NAV_ITEMS: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
|
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 {
|
function MainContent(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -35,6 +39,13 @@ function MainContent(): React.JSX.Element {
|
|||||||
const { isIosDevice, isAndroidDevice, defaultPortraitMode } = useMemo(getMobileDeviceInfo, [])
|
const { isIosDevice, isAndroidDevice, defaultPortraitMode } = useMemo(getMobileDeviceInfo, [])
|
||||||
const [immersiveMode, setImmersiveMode] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
const api = (window as any).api
|
const api = (window as any).api
|
||||||
if (!api) return
|
if (!api) return
|
||||||
@@ -81,6 +92,14 @@ function MainContent(): React.JSX.Element {
|
|||||||
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
||||||
)
|
)
|
||||||
const [moreNavVisible, setMoreNavVisible] = useState(false)
|
const [moreNavVisible, setMoreNavVisible] = useState(false)
|
||||||
|
const [editingNav, setEditingNav] = useState(false)
|
||||||
|
const [editingBottomNavKeys, setEditingBottomNavKeys] = useState<MobileNavKey[]>([])
|
||||||
|
const [editingMoreNavKeys, setEditingMoreNavKeys] = useState<MobileNavKey[]>([])
|
||||||
|
const [draggingNavKey, setDraggingNavKey] = useState<MobileNavKey | null>(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 [isPortraitMode] = useState(defaultPortraitMode)
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
|
||||||
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
|
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
|
||||||
@@ -131,12 +150,7 @@ function MainContent(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
const settingsRes = await (window as any).api.getAllSettings()
|
const settingsRes = await (window as any).api.getAllSettings()
|
||||||
if (settingsRes?.success && settingsRes.data) {
|
if (settingsRes?.success && settingsRes.data) {
|
||||||
setMobileBottomNavItems(
|
setMobileBottomNavItems(normalizeStoredBottomKeys(settingsRes.data.mobile_bottom_nav_items))
|
||||||
sanitizeMobileNavKeys(
|
|
||||||
settingsRes.data.mobile_bottom_nav_items,
|
|
||||||
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,9 +167,7 @@ function MainContent(): React.JSX.Element {
|
|||||||
api
|
api
|
||||||
.onSettingChanged((change: { key?: string; value?: unknown }) => {
|
.onSettingChanged((change: { key?: string; value?: unknown }) => {
|
||||||
if (change?.key !== "mobile_bottom_nav_items") return
|
if (change?.key !== "mobile_bottom_nav_items") return
|
||||||
setMobileBottomNavItems(
|
setMobileBottomNavItems(normalizeStoredBottomKeys(change.value))
|
||||||
sanitizeMobileNavKeys(change.value, DEFAULT_MOBILE_BOTTOM_NAV_ITEMS)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.then((fn: () => void) => {
|
.then((fn: () => void) => {
|
||||||
if (disposed) {
|
if (disposed) {
|
||||||
@@ -370,6 +382,7 @@ function MainContent(): React.JSX.Element {
|
|||||||
const onMenuChange = (v: string) => {
|
const onMenuChange = (v: string) => {
|
||||||
const key = String(v)
|
const key = String(v)
|
||||||
setMoreNavVisible(false)
|
setMoreNavVisible(false)
|
||||||
|
setEditingNav(false)
|
||||||
if (immersiveMode && key !== "home") return
|
if (immersiveMode && key !== "home") return
|
||||||
if (key === "home") navigate("/")
|
if (key === "home") navigate("/")
|
||||||
if (key === "students") navigate("/students")
|
if (key === "students") navigate("/students")
|
||||||
@@ -429,33 +442,96 @@ function MainContent(): React.JSX.Element {
|
|||||||
settings: <SettingOutlined style={{ fontSize: "18px" }} />,
|
settings: <SettingOutlined style={{ fontSize: "18px" }} />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobileBottomVisibleItems = useMemo(() => {
|
const mobileNavAvailableItems = useMemo(
|
||||||
const preferredKeys = sanitizeMobileNavKeys(
|
() => MOBILE_NAV_ITEMS.filter((item) => !(item.adminOnly && permission !== "admin")),
|
||||||
mobileBottomNavItems,
|
[permission]
|
||||||
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
)
|
||||||
).slice()
|
const mobileBottomSelectedKeys = useMemo(() => {
|
||||||
for (const baseKey of ["home", "settings"] as const) {
|
const availableKeySet = new Set(mobileNavAvailableItems.map((item) => item.key))
|
||||||
if (!preferredKeys.includes(baseKey)) {
|
return sanitizeMobileNavKeys(mobileBottomNavItems, [])
|
||||||
preferredKeys.push(baseKey)
|
.filter((key) => availableKeySet.has(key))
|
||||||
}
|
.slice(0, 4)
|
||||||
}
|
}, [mobileBottomNavItems, mobileNavAvailableItems])
|
||||||
|
|
||||||
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(
|
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(
|
const mobileBottomOverflowItems = useMemo(
|
||||||
() => mobileBottomVisibleItems.slice(4),
|
() => mobileNavAvailableItems.filter((item) => !mobileBottomSelectedKeys.includes(item.key)),
|
||||||
[mobileBottomVisibleItems]
|
[mobileNavAvailableItems, mobileBottomSelectedKeys]
|
||||||
)
|
)
|
||||||
const isMoreActive = mobileBottomOverflowItems.some((item) => item.key === activeMenu)
|
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 = (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px dashed var(--ant-color-primary)",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: "color-mix(in srgb, var(--ant-color-primary) 10%, transparent)",
|
||||||
|
minHeight: "44px",
|
||||||
|
padding: "8px 10px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
theme={{
|
theme={{
|
||||||
@@ -575,38 +651,211 @@ function MainContent(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<Drawer
|
||||||
title={t("common.more", "更多")}
|
title={editingNav ? t("common.edit") : t("common.more", "更多")}
|
||||||
|
placement="bottom"
|
||||||
open={moreNavVisible}
|
open={moreNavVisible}
|
||||||
onCancel={() => setMoreNavVisible(false)}
|
onClose={() => {
|
||||||
footer={null}
|
setMoreNavVisible(false)
|
||||||
width={360}
|
setEditingNav(false)
|
||||||
>
|
setDraggingNavKey(null)
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
setDraggingFromList(null)
|
||||||
{mobileBottomOverflowItems.map((item) => (
|
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 ? (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onMenuChange(item.key)}
|
onClick={() => setEditingNav(false)}
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid var(--ss-border-color)",
|
border: "1px solid var(--ss-border-color)",
|
||||||
borderRadius: "10px",
|
borderRadius: "8px",
|
||||||
background: "var(--ss-card-bg)",
|
background: "transparent",
|
||||||
color: "var(--ss-text-main)",
|
color: "var(--ss-text-main)",
|
||||||
height: "44px",
|
padding: "4px 10px",
|
||||||
padding: "0 12px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
fontSize: "14px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{mobileBottomNavIconMap[item.key]}
|
{t("common.finish", "完成")}
|
||||||
<span>{t(item.labelKey)}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
) : (
|
||||||
</div>
|
<button
|
||||||
</Modal>
|
type="button"
|
||||||
|
onClick={openEditNav}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--ss-border-color)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "transparent",
|
||||||
|
color: "var(--ss-text-main)",
|
||||||
|
padding: "4px 10px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("common.edit")}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{editingNav ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: "12px", color: "var(--ss-text-secondary)" }}>
|
||||||
|
{t(
|
||||||
|
"settings.mobile.dragHint",
|
||||||
|
"拖动可调整顺序。前 4 项显示在底栏,其余显示在“更多”。"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
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) => (
|
||||||
|
<div key={group.list} style={{ marginTop: group.list === "more" ? 10 : 0 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: "8px",
|
||||||
|
fontSize: "12px",
|
||||||
|
color: "var(--ss-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
||||||
|
{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 (
|
||||||
|
<div key={`${group.list}-slot-${i}`}>
|
||||||
|
<div
|
||||||
|
onDragEnter={() => 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
|
||||||
|
) : (
|
||||||
|
<div style={{ height: "8px" }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{key && item && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
draggable
|
||||||
|
onDragStart={() => {
|
||||||
|
setDraggingNavKey(key)
|
||||||
|
setDraggingFromList(group.list)
|
||||||
|
}}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDraggingNavKey(null)
|
||||||
|
setDraggingFromList(null)
|
||||||
|
setDragOverSlot(null)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--ss-border-color)",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: "var(--ss-bg-color)",
|
||||||
|
color: "var(--ss-text-main)",
|
||||||
|
minHeight: "44px",
|
||||||
|
padding: "8px 10px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "8px",
|
||||||
|
fontSize: "14px",
|
||||||
|
opacity: draggingNavKey === key ? 0.35 : 1,
|
||||||
|
transform: draggingNavKey === key ? "scale(0.985)" : "scale(1)",
|
||||||
|
transition: "opacity 120ms ease, transform 120ms ease",
|
||||||
|
cursor: "grab",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
{mobileBottomNavIconMap[key]}
|
||||||
|
<span>{t(item.labelKey)}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "4px",
|
||||||
|
color: "var(--ss-text-secondary)",
|
||||||
|
opacity: 0.9,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UpOutlined style={{ fontSize: "11px" }} />
|
||||||
|
<DownOutlined style={{ fontSize: "11px" }} />
|
||||||
|
<HolderOutlined style={{ fontSize: "12px" }} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<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-bg-color)",
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
||||||
|
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ export function ContentArea({
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={<Settings permission={permission} mobileNavigationEnabled={isPortraitMode} />}
|
element={<Settings permission={permission} />}
|
||||||
/>
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
+2
-107
@@ -1,5 +1,4 @@
|
|||||||
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,
|
||||||
@@ -18,12 +17,6 @@ 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"
|
|
||||||
import {
|
|
||||||
MOBILE_NAV_ITEMS,
|
|
||||||
MobileNavKey,
|
|
||||||
sanitizeMobileNavKeys,
|
|
||||||
} from "../shared/mobileNavigation"
|
|
||||||
|
|
||||||
type permissionLevel = "admin" | "points" | "view"
|
type permissionLevel = "admin" | "points" | "view"
|
||||||
type appSettings = {
|
type appSettings = {
|
||||||
@@ -33,11 +26,7 @@ type appSettings = {
|
|||||||
search_keyboard_layout?: "t9" | "qwerty26"
|
search_keyboard_layout?: "t9" | "qwerty26"
|
||||||
disable_search_keyboard?: boolean
|
disable_search_keyboard?: boolean
|
||||||
auto_score_enabled?: 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 (
|
const withTimeout = async (
|
||||||
promise: Promise<any>,
|
promise: Promise<any>,
|
||||||
ms: number,
|
ms: number,
|
||||||
@@ -58,11 +47,8 @@ const withTimeout = async (
|
|||||||
|
|
||||||
export const Settings: React.FC<{
|
export const Settings: React.FC<{
|
||||||
permission: permissionLevel
|
permission: permissionLevel
|
||||||
mobileNavigationEnabled?: boolean
|
}> = ({ permission }) => {
|
||||||
}> = ({ 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")
|
||||||
@@ -73,7 +59,6 @@ export const Settings: React.FC<{
|
|||||||
window_zoom: "1.0",
|
window_zoom: "1.0",
|
||||||
search_keyboard_layout: "qwerty26",
|
search_keyboard_layout: "qwerty26",
|
||||||
disable_search_keyboard: false,
|
disable_search_keyboard: false,
|
||||||
mobile_bottom_nav_items: DEFAULT_MOBILE_BOTTOM_NAV_ITEMS,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const [securityStatus, setSecurityStatus] = useState<{
|
const [securityStatus, setSecurityStatus] = useState<{
|
||||||
@@ -148,19 +133,6 @@ export const Settings: React.FC<{
|
|||||||
)
|
)
|
||||||
}, [permission, t])
|
}, [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") => {
|
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 } }))
|
||||||
}
|
}
|
||||||
@@ -184,13 +156,7 @@ export const Settings: React.FC<{
|
|||||||
if (!(window as any).api) return
|
if (!(window as any).api) return
|
||||||
const res = await (window as any).api.getAllSettings()
|
const res = await (window as any).api.getAllSettings()
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setSettings({
|
setSettings(res.data)
|
||||||
...res.data,
|
|
||||||
mobile_bottom_nav_items: sanitizeMobileNavKeys(
|
|
||||||
res.data.mobile_bottom_nav_items,
|
|
||||||
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
|
|
||||||
),
|
|
||||||
})
|
|
||||||
setPgConnectionString(res.data.pg_connection_string || "")
|
setPgConnectionString(res.data.pg_connection_string || "")
|
||||||
setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" })
|
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) }
|
return { ...prev, disable_search_keyboard: Boolean(change.value) }
|
||||||
if (change?.key === "auto_score_enabled")
|
if (change?.key === "auto_score_enabled")
|
||||||
return { ...prev, auto_score_enabled: change.value }
|
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
|
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 = [
|
const tabItems = [
|
||||||
{
|
{
|
||||||
key: "appearance",
|
key: "appearance",
|
||||||
@@ -776,27 +721,6 @@ export const Settings: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{mobileNavigationEnabled && (
|
|
||||||
<Form.Item label={t("settings.mobile.bottomNav.label")}>
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
value={settings.mobile_bottom_nav_items || DEFAULT_MOBILE_BOTTOM_NAV_ITEMS}
|
|
||||||
onChange={(v) => handleMobileBottomNavChange(v as string[])}
|
|
||||||
style={{ width: "100%", maxWidth: "520px" }}
|
|
||||||
disabled={!canAdmin}
|
|
||||||
options={MOBILE_NAV_ITEMS.map((item) => ({
|
|
||||||
value: item.key,
|
|
||||||
label: t(item.labelKey),
|
|
||||||
disabled: Boolean(item.adminOnly) && !canAdmin,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{ marginTop: "4px", fontSize: "12px", color: "var(--ss-text-secondary)" }}
|
|
||||||
>
|
|
||||||
{t("settings.mobile.bottomNav.hint")}
|
|
||||||
</div>
|
|
||||||
</Form.Item>
|
|
||||||
)}
|
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
),
|
),
|
||||||
@@ -1359,35 +1283,6 @@ export const Settings: React.FC<{
|
|||||||
{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
|
||||||
|
|||||||
@@ -167,7 +167,13 @@
|
|||||||
"bottomNav": {
|
"bottomNav": {
|
||||||
"label": "Bottom Navigation Features",
|
"label": "Bottom Navigation Features",
|
||||||
"hint": "Choose which features appear in the mobile bottom bar. The bar shows up to 4 items; extra items go under \"More\"."
|
"hint": "Choose which features appear in the mobile bottom bar. The bar shows up to 4 items; extra items go under \"More\"."
|
||||||
}
|
},
|
||||||
|
"dragHint": "Drag to reorder. The first 4 items appear in the bottom bar; the rest stay in \"More\".",
|
||||||
|
"dropHere": "Release to drop here",
|
||||||
|
"bottomSection": "Bottom Bar (up to 4)",
|
||||||
|
"moreSection": "More",
|
||||||
|
"moveToMore": "Move to More",
|
||||||
|
"moveToBottom": "Move to Bottom"
|
||||||
},
|
},
|
||||||
"zoomUpdated": "Interface zoom updated",
|
"zoomUpdated": "Interface zoom updated",
|
||||||
"updateFailed": "Update failed",
|
"updateFailed": "Update failed",
|
||||||
|
|||||||
@@ -167,7 +167,13 @@
|
|||||||
"bottomNav": {
|
"bottomNav": {
|
||||||
"label": "底部导航功能",
|
"label": "底部导航功能",
|
||||||
"hint": "可选择要出现在手机底部导航的功能。底栏最多展示 4 项,其余会自动收纳到“更多”。"
|
"hint": "可选择要出现在手机底部导航的功能。底栏最多展示 4 项,其余会自动收纳到“更多”。"
|
||||||
}
|
},
|
||||||
|
"dragHint": "拖动可调整顺序。前 4 项显示在底栏,其余显示在“更多”。",
|
||||||
|
"dropHere": "松手后放到这里",
|
||||||
|
"bottomSection": "底栏(最多 4 个)",
|
||||||
|
"moreSection": "更多",
|
||||||
|
"moveToMore": "移到更多",
|
||||||
|
"moveToBottom": "移到底栏"
|
||||||
},
|
},
|
||||||
"zoomUpdated": "界面缩放已更新",
|
"zoomUpdated": "界面缩放已更新",
|
||||||
"updateFailed": "更新失败",
|
"updateFailed": "更新失败",
|
||||||
|
|||||||
Reference in New Issue
Block a user