diff --git a/scripts/tauri-dev.cjs b/scripts/tauri-dev.cjs index 608c935..2b723ce 100644 --- a/scripts/tauri-dev.cjs +++ b/scripts/tauri-dev.cjs @@ -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}`, }, } diff --git a/src-tauri/src/commands/database.rs b/src-tauri/src/commands/database.rs index 6dd6909..dd2f8bb 100644 --- a/src-tauri/src/commands/database.rs +++ b/src-tauri/src/commands/database.rs @@ -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 = 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 = 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 = 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 = 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 = 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 = 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; } } diff --git a/src-tauri/src/services/settings.rs b/src-tauri/src/services/settings.rs index 670cecf..b41660f 100644 --- a/src-tauri/src/services/settings.rs +++ b/src-tauri/src/services/settings.rs @@ -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" + ]), + }, } } diff --git a/src/App.tsx b/src/App.tsx index 995e433..da93423 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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( + 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 = { + home: , + students: , + score: , + "auto-score": , + "reward-settings": , + boards: , + leaderboard: , + settlements: , + reasons: , + settings: , + } + + const mobileBottomVisibleItems = useMemo(() => { + const preferredKeys = sanitizeMobileNavKeys( + mobileBottomNavItems, + DEFAULT_MOBILE_BOTTOM_NAV_ITEMS + ).slice() + for (const baseKey of ["home", "settings"] as const) { + if (!preferredKeys.includes(baseKey)) { + preferredKeys.push(baseKey) + } + } + + return preferredKeys + .map((key) => MOBILE_NAV_ITEMS.find((item) => item.key === key)) + .filter((item): item is (typeof MOBILE_NAV_ITEMS)[number] => Boolean(item)) + .filter((item) => !(item.adminOnly && permission !== "admin")) + }, [mobileBottomNavItems, permission]) + + const mobileBottomPrimaryItems = useMemo( + () => mobileBottomVisibleItems.slice(0, 4), + [mobileBottomVisibleItems] + ) + const mobileBottomOverflowItems = useMemo( + () => mobileBottomVisibleItems.slice(4), + [mobileBottomVisibleItems] + ) + const isMoreActive = mobileBottomOverflowItems.some((item) => item.key === activeMenu) return (
- + ))} + {mobileBottomOverflowItems.length > 0 && ( + - + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "2px", + fontSize: "12px", + }} + > + + {t("common.more", "更多")} + + )}
)} + setMoreNavVisible(false)} + footer={null} + width={360} + > +
+ {mobileBottomOverflowItems.map((item) => ( + + ))} +
+
+ setWizardVisible(false)} /> { + 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 && ( -