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
+1 -1
View File
@@ -34,7 +34,7 @@ async function main() {
const port = await findAvailablePort(START_PORT)
const overrideConfig = {
build: {
beforeDevCommand: `npm run dev -- --host --port ${port} --strictPort`,
beforeDevCommand: `npm run dev -- --host --port ${port}`,
devUrl: `http://localhost:${port}`,
},
}
+202 -114
View File
@@ -828,139 +828,227 @@ async fn db_sync_apply_internal(
};
let local_students = load_students(&local_conn).await?;
let remote_students = load_students(&remote_conn).await?;
let local_reasons = load_reasons(&local_conn).await?;
let remote_reasons = load_reasons(&remote_conn).await?;
let local_tags = load_tags(&local_conn).await?;
let remote_tags = load_tags(&remote_conn).await?;
let local_events = load_events(&local_conn).await?;
let remote_events = load_events(&remote_conn).await?;
let local_reward_settings = load_reward_settings(&local_conn).await?;
let remote_reward_settings = load_reward_settings(&remote_conn).await?;
let local_reward_redemptions = load_reward_redemptions(&local_conn).await?;
let remote_reward_redemptions = load_reward_redemptions(&remote_conn).await?;
let local_pairs = load_student_tag_pairs(&local_conn).await?;
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
let (preferred, target) = if strategy == ConflictStrategy::KeepLocal {
(&local_conn, &remote_conn)
} else {
(&remote_conn, &local_conn)
};
let mut synced_records = 0usize;
let mut resolved_conflicts = 0usize;
for student in local_students.values() {
if upsert_student(&remote_conn, student).await? {
synced_records += 1;
}
}
let remote_students_after = load_students(&remote_conn).await?;
for student in remote_students_after.values() {
if upsert_student(&local_conn, student).await? {
synced_records += 1;
}
}
for reason in local_reasons.values() {
if upsert_reason(&remote_conn, reason).await? {
synced_records += 1;
}
}
let remote_reasons_after = load_reasons(&remote_conn).await?;
for reason in remote_reasons_after.values() {
if upsert_reason(&local_conn, reason).await? {
synced_records += 1;
}
}
for tag in local_tags.values() {
if upsert_tag(&remote_conn, tag).await? {
synced_records += 1;
}
}
let remote_tags_after = load_tags(&remote_conn).await?;
for tag in remote_tags_after.values() {
if upsert_tag(&local_conn, tag).await? {
synced_records += 1;
}
}
for event in local_events.values() {
if upsert_event(&remote_conn, event).await? {
synced_records += 1;
}
}
let remote_events_after = load_events(&remote_conn).await?;
for event in remote_events_after.values() {
if upsert_event(&local_conn, event).await? {
synced_records += 1;
}
}
for reward in local_reward_settings.values() {
if upsert_reward_setting(&remote_conn, reward).await? {
synced_records += 1;
}
}
let remote_reward_settings_after = load_reward_settings(&remote_conn).await?;
for reward in remote_reward_settings_after.values() {
if upsert_reward_setting(&local_conn, reward).await? {
synced_records += 1;
}
}
for redemption in local_reward_redemptions.values() {
if upsert_reward_redemption(&remote_conn, redemption).await? {
synced_records += 1;
}
}
let remote_reward_redemptions_after = load_reward_redemptions(&remote_conn).await?;
for redemption in remote_reward_redemptions_after.values() {
if upsert_reward_redemption(&local_conn, redemption).await? {
synced_records += 1;
// Deterministic per-key merge:
// 1) local_only => remote
// 2) remote_only => local
// 3) conflict => resolve by strategy (winner -> loser)
let student_keys: std::collections::HashSet<String> = local_students
.keys()
.chain(remote_students.keys())
.cloned()
.collect();
for key in student_keys {
match (local_students.get(&key), remote_students.get(&key)) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_student(&remote_conn, local).await?
} else {
upsert_student(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_student(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_student(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
for pair in local_pairs.union(&remote_pairs) {
if ensure_student_tag_pair(&local_conn, pair).await? {
synced_records += 1;
let reason_keys: std::collections::HashSet<String> = local_reasons
.keys()
.chain(remote_reasons.keys())
.cloned()
.collect();
for key in reason_keys {
match (local_reasons.get(&key), remote_reasons.get(&key)) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_reason(&remote_conn, local).await?
} else {
upsert_reason(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_reason(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_reason(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
let tag_keys: std::collections::HashSet<String> = local_tags
.keys()
.chain(remote_tags.keys())
.cloned()
.collect();
for key in tag_keys {
match (local_tags.get(&key), remote_tags.get(&key)) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_tag(&remote_conn, local).await?
} else {
upsert_tag(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_tag(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_tag(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
let event_keys: std::collections::HashSet<String> = local_events
.keys()
.chain(remote_events.keys())
.cloned()
.collect();
for key in event_keys {
match (local_events.get(&key), remote_events.get(&key)) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_event(&remote_conn, local).await?
} else {
upsert_event(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_event(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_event(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
let reward_setting_keys: std::collections::HashSet<String> = local_reward_settings
.keys()
.chain(remote_reward_settings.keys())
.cloned()
.collect();
for key in reward_setting_keys {
match (
local_reward_settings.get(&key),
remote_reward_settings.get(&key),
) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_reward_setting(&remote_conn, local).await?
} else {
upsert_reward_setting(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_reward_setting(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_reward_setting(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
let redemption_keys: std::collections::HashSet<String> = local_reward_redemptions
.keys()
.chain(remote_reward_redemptions.keys())
.cloned()
.collect();
for key in redemption_keys {
match (
local_reward_redemptions.get(&key),
remote_reward_redemptions.get(&key),
) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_reward_redemption(&remote_conn, local).await?
} else {
upsert_reward_redemption(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_reward_redemption(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_reward_redemption(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
for pair in local_pairs.difference(&remote_pairs) {
if ensure_student_tag_pair(&remote_conn, pair).await? {
synced_records += 1;
}
}
let preferred_students = load_students(preferred).await?;
for student in preferred_students.values() {
if upsert_student(target, student).await? {
resolved_conflicts += 1;
}
}
let preferred_reasons = load_reasons(preferred).await?;
for reason in preferred_reasons.values() {
if upsert_reason(target, reason).await? {
resolved_conflicts += 1;
}
}
let preferred_tags = load_tags(preferred).await?;
for tag in preferred_tags.values() {
if upsert_tag(target, tag).await? {
resolved_conflicts += 1;
}
}
let preferred_events = load_events(preferred).await?;
for event in preferred_events.values() {
if upsert_event(target, event).await? {
resolved_conflicts += 1;
}
}
let preferred_reward_settings = load_reward_settings(preferred).await?;
for reward in preferred_reward_settings.values() {
if upsert_reward_setting(target, reward).await? {
resolved_conflicts += 1;
}
}
let preferred_reward_redemptions = load_reward_redemptions(preferred).await?;
for redemption in preferred_reward_redemptions.values() {
if upsert_reward_redemption(target, redemption).await? {
resolved_conflicts += 1;
}
}
let preferred_pairs = load_student_tag_pairs(preferred).await?;
for pair in &preferred_pairs {
if ensure_student_tag_pair(target, pair).await? {
resolved_conflicts += 1;
for pair in remote_pairs.difference(&local_pairs) {
if ensure_student_tag_pair(&local_conn, pair).await? {
synced_records += 1;
}
}
+73
View File
@@ -17,6 +17,7 @@ pub struct SettingsSpec {
pub dashboards_config: JsonValue,
pub pg_connection_string: String,
pub pg_connection_status: JsonValue,
pub mobile_bottom_nav_items: JsonValue,
}
impl Default for SettingsSpec {
@@ -34,6 +35,18 @@ impl Default for SettingsSpec {
dashboards_config: JsonValue::Array(vec![]),
pg_connection_string: String::new(),
pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}),
mobile_bottom_nav_items: serde_json::json!([
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings"
]),
}
}
}
@@ -52,6 +65,7 @@ pub enum SettingsKey {
DashboardsConfig,
PgConnectionString,
PgConnectionStatus,
MobileBottomNavItems,
}
impl SettingsKey {
@@ -69,6 +83,7 @@ impl SettingsKey {
SettingsKey::DashboardsConfig => "dashboards_config",
SettingsKey::PgConnectionString => "pg_connection_string",
SettingsKey::PgConnectionStatus => "pg_connection_status",
SettingsKey::MobileBottomNavItems => "mobile_bottom_nav_items",
}
}
@@ -86,6 +101,7 @@ impl SettingsKey {
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
"mobile_bottom_nav_items" => Some(SettingsKey::MobileBottomNavItems),
_ => None,
}
}
@@ -383,6 +399,48 @@ impl SettingsService {
},
);
defs.insert(
SettingsKey::MobileBottomNavItems,
SettingDefinition {
kind: SettingValueKind::Json,
default_value: SettingsValue::Json(serde_json::json!([
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings"
])),
write_permission: PermissionRequirement::Admin,
validate: Some(|v| {
if let SettingsValue::Json(JsonValue::Array(items)) = v {
let allowed = [
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings",
];
return items.iter().all(|item| {
item.as_str()
.map(|s| allowed.contains(&s))
.unwrap_or(false)
});
}
false
}),
},
);
defs
}
@@ -521,6 +579,21 @@ impl SettingsService {
SettingsValue::Json(j) => j,
_ => serde_json::json!({"connected": false, "type": "sqlite"}),
},
mobile_bottom_nav_items: match self.get_value(SettingsKey::MobileBottomNavItems) {
SettingsValue::Json(j) => j,
_ => serde_json::json!([
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings"
]),
},
}
}
+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
+85 -20
View File
@@ -5,8 +5,9 @@ import {
MenuUnfoldOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
LeftOutlined,
} from "@ant-design/icons"
import { Routes, Route, Navigate, useLocation } from "react-router-dom"
import { Routes, Route, Navigate, useLocation, useNavigate } from "react-router-dom"
import { useTranslation } from "react-i18next"
import { WindowControls } from "./WindowControls"
import appLogo from "../assets/logoHD.svg"
@@ -92,8 +93,35 @@ export function ContentArea({
}: ContentAreaProps): React.JSX.Element {
const { t } = useTranslation()
const location = useLocation()
const navigate = useNavigate()
const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home")
const shouldAnimateSubPage = isPortraitMode && isSubPage
const normalizedPath = location.pathname === "/" ? "/home" : location.pathname
const isMobileHeaderMode = isPortraitMode && isMobileDevice && !immersiveMode
const isPrimaryMobilePage =
normalizedPath.startsWith("/home") || normalizedPath.startsWith("/settings")
const showMobileBack = isMobileHeaderMode && !isPrimaryMobilePage
const mobilePageTitle = (() => {
if (normalizedPath.startsWith("/home")) return t("sidebar.home")
if (normalizedPath.startsWith("/students")) return t("sidebar.students")
if (normalizedPath.startsWith("/score")) return t("sidebar.score")
if (normalizedPath.startsWith("/boards")) return t("sidebar.boards")
if (normalizedPath.startsWith("/leaderboard")) return t("sidebar.leaderboard")
if (normalizedPath.startsWith("/settlements")) return t("sidebar.settlements")
if (normalizedPath.startsWith("/reasons")) return t("sidebar.reasons")
if (normalizedPath.startsWith("/auto-score")) return t("sidebar.autoScore")
if (normalizedPath.startsWith("/reward-settings")) return t("sidebar.rewardSettings")
if (normalizedPath.startsWith("/settings")) return t("sidebar.settings")
return "SecScore"
})()
const handleMobileBack = () => {
if (window.history.length > 1) {
navigate(-1)
return
}
navigate("/settings")
}
useEffect(() => {
let cancelled = false
@@ -165,27 +193,64 @@ export function ContentArea({
} as React.CSSProperties
}
>
{!immersiveMode && showSidebarToggle && (
<Button
type="text"
size="small"
onClick={onToggleSidebar}
icon={
floatingExpand && sidebarCollapsed ? (
floatingExpanded ? (
<MenuFoldOutlined />
) : (
{isMobileHeaderMode ? (
<div
style={{
display: "flex",
alignItems: "center",
minWidth: 0,
gap: "6px",
}}
>
{showMobileBack && (
<Button
type="text"
size="small"
onClick={handleMobileBack}
icon={<LeftOutlined />}
title={t("settlements.back")}
style={{ width: "32px", height: "32px" }}
/>
)}
<div
style={{
color: "var(--ss-text-main)",
fontSize: "14px",
fontWeight: 600,
userSelect: "none",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "40vw",
}}
>
{mobilePageTitle}
</div>
</div>
) : (
!immersiveMode &&
showSidebarToggle && (
<Button
type="text"
size="small"
onClick={onToggleSidebar}
icon={
floatingExpand && sidebarCollapsed ? (
floatingExpanded ? (
<MenuFoldOutlined />
) : (
<MenuUnfoldOutlined />
)
) : sidebarCollapsed ? (
<MenuUnfoldOutlined />
) : (
<MenuFoldOutlined />
)
) : sidebarCollapsed ? (
<MenuUnfoldOutlined />
) : (
<MenuFoldOutlined />
)
}
title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"}
style={{ width: "32px", height: "32px" }}
/>
}
title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"}
style={{ width: "32px", height: "32px" }}
/>
)
)}
</div>
<div
+68 -16
View File
@@ -19,6 +19,11 @@ 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 = {
@@ -28,8 +33,11 @@ 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<any>,
ms: number,
@@ -65,6 +73,7 @@ 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<{
@@ -140,21 +149,15 @@ export const Settings: React.FC<{
}, [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 },
],
() =>
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]
)
@@ -181,7 +184,13 @@ 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)
setSettings({
...res.data,
mobile_bottom_nav_items: sanitizeMobileNavKeys(
res.data.mobile_bottom_nav_items,
DEFAULT_MOBILE_BOTTOM_NAV_ITEMS
),
})
setPgConnectionString(res.data.pg_connection_string || "")
setPgConnectionStatus(res.data.pg_connection_status || { connected: true, type: "sqlite" })
}
@@ -211,6 +220,15 @@ 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
})
})
@@ -619,6 +637,18 @@ 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",
@@ -745,6 +775,28 @@ export const Settings: React.FC<{
{t("settings.zoomHint")}
</div>
</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>
</Card>
),
+9 -1
View File
@@ -34,7 +34,8 @@
"pleaseSelect": "Please select",
"pleaseEnter": "Please enter",
"readOnly": "Read-only mode",
"none": "None"
"none": "None",
"more": "More"
},
"oobe": {
"title": "Welcome to SecScore",
@@ -161,6 +162,13 @@
"saveFailed": "Save failed"
},
"zoomHint": "Adjust the overall size of the application interface",
"mobile": {
"navigationTitle": "Page Shortcuts",
"bottomNav": {
"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\"."
}
},
"zoomUpdated": "Interface zoom updated",
"updateFailed": "Update failed",
"security": {
+9 -1
View File
@@ -34,7 +34,8 @@
"pleaseSelect": "请选择",
"pleaseEnter": "请输入",
"readOnly": "当前为只读权限",
"none": "无"
"none": "无",
"more": "更多"
},
"oobe": {
"title": "欢迎使用 SecScore",
@@ -161,6 +162,13 @@
"saveFailed": "保存失败"
},
"zoomHint": "调节应用界面的整体大小",
"mobile": {
"navigationTitle": "页面入口",
"bottomNav": {
"label": "底部导航功能",
"hint": "可选择要出现在手机底部导航的功能。底栏最多展示 4 项,其余会自动收纳到“更多”。"
}
},
"zoomUpdated": "界面缩放已更新",
"updateFailed": "更新失败",
"security": {
+2
View File
@@ -35,6 +35,7 @@ export type settingsKey =
| "dashboards_config"
| "pg_connection_string"
| "pg_connection_status"
| "mobile_bottom_nav_items"
export interface settingsSpec {
is_wizard_completed: boolean
@@ -53,6 +54,7 @@ export interface settingsSpec {
type: "sqlite" | "postgresql"
error?: string
}
mobile_bottom_nav_items: string[]
}
const api = {
+55
View File
@@ -0,0 +1,55 @@
export const MOBILE_NAV_ALL_KEYS = [
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings",
] as const
export type MobileNavKey = (typeof MOBILE_NAV_ALL_KEYS)[number]
export interface MobileNavItemConfig {
key: MobileNavKey
path: string
labelKey: string
adminOnly?: boolean
}
export const MOBILE_NAV_ITEMS: MobileNavItemConfig[] = [
{ key: "home", path: "/home", labelKey: "sidebar.home" },
{ key: "students", path: "/students", labelKey: "sidebar.students", adminOnly: true },
{ key: "score", path: "/score", labelKey: "sidebar.score" },
{ key: "auto-score", path: "/auto-score", labelKey: "sidebar.autoScore" },
{
key: "reward-settings",
path: "/reward-settings",
labelKey: "sidebar.rewardSettings",
adminOnly: true,
},
{ key: "boards", path: "/boards", labelKey: "sidebar.boards" },
{ key: "leaderboard", path: "/leaderboard", labelKey: "sidebar.leaderboard" },
{ key: "settlements", path: "/settlements", labelKey: "sidebar.settlements" },
{ key: "reasons", path: "/reasons", labelKey: "sidebar.reasons", adminOnly: true },
{ key: "settings", path: "/settings", labelKey: "sidebar.settings" },
]
const MOBILE_NAV_KEY_SET = new Set<string>(MOBILE_NAV_ALL_KEYS)
export const sanitizeMobileNavKeys = (
input: unknown,
fallback: MobileNavKey[] = MOBILE_NAV_ITEMS.map((item) => item.key)
): MobileNavKey[] => {
if (!Array.isArray(input)) return fallback
const deduped: MobileNavKey[] = []
for (const rawKey of input) {
if (typeof rawKey !== "string" || !MOBILE_NAV_KEY_SET.has(rawKey)) continue
const key = rawKey as MobileNavKey
if (!deduped.includes(key)) deduped.push(key)
}
return deduped.length > 0 ? deduped : fallback
}
+1 -1
View File
@@ -20,7 +20,7 @@ export default defineConfig({
server: {
host: process.env.TAURI_DEV_HOST || false,
port: 1420,
strictPort: true,
strictPort: false,
hmr: process.env.TAURI_DEV_HOST
? {
protocol: "ws",