mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 06:04:22 +08:00
feat: 支持移动端底部导航自定义与更多收纳
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user