mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-19 19:04:23 +08:00
feat: 支持移动端底部导航自定义与更多收纳
This commit is contained in:
@@ -34,7 +34,7 @@ async function main() {
|
|||||||
const port = await findAvailablePort(START_PORT)
|
const port = await findAvailablePort(START_PORT)
|
||||||
const overrideConfig = {
|
const overrideConfig = {
|
||||||
build: {
|
build: {
|
||||||
beforeDevCommand: `npm run dev -- --host --port ${port} --strictPort`,
|
beforeDevCommand: `npm run dev -- --host --port ${port}`,
|
||||||
devUrl: `http://localhost:${port}`,
|
devUrl: `http://localhost:${port}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+195
-107
@@ -828,139 +828,227 @@ async fn db_sync_apply_internal(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let local_students = load_students(&local_conn).await?;
|
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 local_reasons = load_reasons(&local_conn).await?;
|
||||||
|
let remote_reasons = load_reasons(&remote_conn).await?;
|
||||||
let local_tags = load_tags(&local_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 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 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 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 local_pairs = load_student_tag_pairs(&local_conn).await?;
|
||||||
let remote_pairs = load_student_tag_pairs(&remote_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 synced_records = 0usize;
|
||||||
let mut resolved_conflicts = 0usize;
|
let mut resolved_conflicts = 0usize;
|
||||||
|
|
||||||
for student in local_students.values() {
|
// Deterministic per-key merge:
|
||||||
if upsert_student(&remote_conn, student).await? {
|
// 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;
|
synced_records += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let remote_students_after = load_students(&remote_conn).await?;
|
(None, Some(remote)) => {
|
||||||
for student in remote_students_after.values() {
|
if upsert_student(&local_conn, remote).await? {
|
||||||
if upsert_student(&local_conn, student).await? {
|
|
||||||
synced_records += 1;
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for pair in local_pairs.union(&remote_pairs) {
|
let reason_keys: std::collections::HashSet<String> = local_reasons
|
||||||
if ensure_student_tag_pair(&local_conn, pair).await? {
|
.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;
|
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? {
|
if ensure_student_tag_pair(&remote_conn, pair).await? {
|
||||||
synced_records += 1;
|
synced_records += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for pair in remote_pairs.difference(&local_pairs) {
|
||||||
let preferred_students = load_students(preferred).await?;
|
if ensure_student_tag_pair(&local_conn, pair).await? {
|
||||||
for student in preferred_students.values() {
|
synced_records += 1;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub struct SettingsSpec {
|
|||||||
pub dashboards_config: JsonValue,
|
pub dashboards_config: JsonValue,
|
||||||
pub pg_connection_string: String,
|
pub pg_connection_string: String,
|
||||||
pub pg_connection_status: JsonValue,
|
pub pg_connection_status: JsonValue,
|
||||||
|
pub mobile_bottom_nav_items: JsonValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for SettingsSpec {
|
impl Default for SettingsSpec {
|
||||||
@@ -34,6 +35,18 @@ impl Default for SettingsSpec {
|
|||||||
dashboards_config: JsonValue::Array(vec![]),
|
dashboards_config: JsonValue::Array(vec![]),
|
||||||
pg_connection_string: String::new(),
|
pg_connection_string: String::new(),
|
||||||
pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}),
|
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,
|
DashboardsConfig,
|
||||||
PgConnectionString,
|
PgConnectionString,
|
||||||
PgConnectionStatus,
|
PgConnectionStatus,
|
||||||
|
MobileBottomNavItems,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SettingsKey {
|
impl SettingsKey {
|
||||||
@@ -69,6 +83,7 @@ impl SettingsKey {
|
|||||||
SettingsKey::DashboardsConfig => "dashboards_config",
|
SettingsKey::DashboardsConfig => "dashboards_config",
|
||||||
SettingsKey::PgConnectionString => "pg_connection_string",
|
SettingsKey::PgConnectionString => "pg_connection_string",
|
||||||
SettingsKey::PgConnectionStatus => "pg_connection_status",
|
SettingsKey::PgConnectionStatus => "pg_connection_status",
|
||||||
|
SettingsKey::MobileBottomNavItems => "mobile_bottom_nav_items",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +101,7 @@ impl SettingsKey {
|
|||||||
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
|
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
|
||||||
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
|
||||||
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
|
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
|
||||||
|
"mobile_bottom_nav_items" => Some(SettingsKey::MobileBottomNavItems),
|
||||||
_ => None,
|
_ => 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
|
defs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +579,21 @@ impl SettingsService {
|
|||||||
SettingsValue::Json(j) => j,
|
SettingsValue::Json(j) => j,
|
||||||
_ => serde_json::json!({"connected": false, "type": "sqlite"}),
|
_ => 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"
|
||||||
|
]),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+149
-12
@@ -1,5 +1,16 @@
|
|||||||
import { Layout, Modal, Input, message, ConfigProvider, theme as antTheme } from "antd"
|
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 { 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"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
@@ -7,6 +18,13 @@ import { Sidebar } from "./components/Sidebar"
|
|||||||
import { ContentArea } from "./components/ContentArea"
|
import { ContentArea } from "./components/ContentArea"
|
||||||
import { OOBE } from "./components/OOBE/OOBE"
|
import { OOBE } from "./components/OOBE/OOBE"
|
||||||
import { ThemeProvider, useTheme } from "./contexts/ThemeContext"
|
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 {
|
function MainContent(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -59,6 +77,10 @@ function MainContent(): React.JSX.Element {
|
|||||||
const [authVisible, setAuthVisible] = useState(false)
|
const [authVisible, setAuthVisible] = useState(false)
|
||||||
const [authPassword, setAuthPassword] = useState("")
|
const [authPassword, setAuthPassword] = useState("")
|
||||||
const [authLoading, setAuthLoading] = useState(false)
|
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 [isPortraitMode] = useState(defaultPortraitMode)
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(defaultPortraitMode)
|
||||||
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
|
const [floatingSidebarExpanded, setFloatingSidebarExpanded] = useState(false)
|
||||||
@@ -107,11 +129,49 @@ function MainContent(): React.JSX.Element {
|
|||||||
setHasAnyPassword(anyPwd)
|
setHasAnyPassword(anyPwd)
|
||||||
if (anyPwd && authRes.data.permission === "view") setAuthVisible(true)
|
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()
|
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(() => {
|
useEffect(() => {
|
||||||
const api = (window as any).api
|
const api = (window as any).api
|
||||||
if (!api || !isIosDevice) return
|
if (!api || !isIosDevice) return
|
||||||
@@ -309,6 +369,7 @@ function MainContent(): React.JSX.Element {
|
|||||||
|
|
||||||
const onMenuChange = (v: string) => {
|
const onMenuChange = (v: string) => {
|
||||||
const key = String(v)
|
const key = String(v)
|
||||||
|
setMoreNavVisible(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")
|
||||||
@@ -355,6 +416,45 @@ function MainContent(): React.JSX.Element {
|
|||||||
const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9"
|
const brandColor = currentTheme?.config?.tdesign?.brandColor || "#0052D9"
|
||||||
const isMobileDevice = isIosDevice || isAndroidDevice
|
const isMobileDevice = isIosDevice || isAndroidDevice
|
||||||
const showMobileBottomNav = isPortraitMode && !immersiveMode
|
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 (
|
return (
|
||||||
<ConfigProvider
|
<ConfigProvider
|
||||||
@@ -423,15 +523,17 @@ function MainContent(): React.JSX.Element {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", height: "60px" }}>
|
<div style={{ display: "flex", height: "60px" }}>
|
||||||
|
{mobileBottomPrimaryItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onMenuChange("home")}
|
onClick={() => onMenuChange(item.key)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
border: "none",
|
border: "none",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
color:
|
color:
|
||||||
activeMenu === "home"
|
activeMenu === item.key
|
||||||
? "var(--ant-color-primary)"
|
? "var(--ant-color-primary)"
|
||||||
: "var(--ss-text-secondary, var(--ss-text-main))",
|
: "var(--ss-text-secondary, var(--ss-text-main))",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -442,20 +544,21 @@ function MainContent(): React.JSX.Element {
|
|||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HomeOutlined style={{ fontSize: "18px" }} />
|
{mobileBottomNavIconMap[item.key]}
|
||||||
<span>{t("sidebar.home")}</span>
|
<span>{t(item.labelKey)}</span>
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
{mobileBottomOverflowItems.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onMenuChange("settings")}
|
onClick={() => setMoreNavVisible(true)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
border: "none",
|
border: "none",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
color:
|
color: isMoreActive
|
||||||
activeMenu === "home"
|
? "var(--ant-color-primary)"
|
||||||
? "var(--ss-text-secondary, var(--ss-text-main))"
|
: "var(--ss-text-secondary, var(--ss-text-main))",
|
||||||
: "var(--ant-color-primary)",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -464,13 +567,47 @@ function MainContent(): React.JSX.Element {
|
|||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SettingOutlined style={{ fontSize: "18px" }} />
|
<MoreOutlined style={{ fontSize: "18px" }} />
|
||||||
<span>{t("sidebar.settings")}</span>
|
<span>{t("common.more", "更多")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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)} />
|
<OOBE visible={wizardVisible} onComplete={() => setWizardVisible(false)} />
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import {
|
|||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
FullscreenOutlined,
|
FullscreenOutlined,
|
||||||
FullscreenExitOutlined,
|
FullscreenExitOutlined,
|
||||||
|
LeftOutlined,
|
||||||
} from "@ant-design/icons"
|
} 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 { useTranslation } from "react-i18next"
|
||||||
import { WindowControls } from "./WindowControls"
|
import { WindowControls } from "./WindowControls"
|
||||||
import appLogo from "../assets/logoHD.svg"
|
import appLogo from "../assets/logoHD.svg"
|
||||||
@@ -92,8 +93,35 @@ export function ContentArea({
|
|||||||
}: ContentAreaProps): React.JSX.Element {
|
}: ContentAreaProps): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home")
|
const isSubPage = location.pathname !== "/" && !location.pathname.startsWith("/home")
|
||||||
const shouldAnimateSubPage = isPortraitMode && isSubPage
|
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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -165,7 +193,43 @@ export function ContentArea({
|
|||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!immersiveMode && showSidebarToggle && (
|
{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
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -186,6 +250,7 @@ export function ContentArea({
|
|||||||
title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"}
|
title={sidebarCollapsed ? "展开导航栏" : "收起导航栏"}
|
||||||
style={{ width: "32px", height: "32px" }}
|
style={{ width: "32px", height: "32px" }}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+68
-16
@@ -19,6 +19,11 @@ 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 { 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 = {
|
||||||
@@ -28,8 +33,11 @@ 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,
|
||||||
@@ -65,6 +73,7 @@ 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<{
|
||||||
@@ -140,21 +149,15 @@ export const Settings: React.FC<{
|
|||||||
}, [permission, t])
|
}, [permission, t])
|
||||||
|
|
||||||
const mobilePageItems = useMemo(
|
const mobilePageItems = useMemo(
|
||||||
() => [
|
() =>
|
||||||
{ key: "students", path: "/students", label: t("sidebar.students"), disabled: !canAdmin },
|
MOBILE_NAV_ITEMS.filter((item) => item.key !== "home" && item.key !== "settings").map(
|
||||||
{ key: "score", path: "/score", label: t("sidebar.score"), disabled: false },
|
(item) => ({
|
||||||
{ key: "auto-score", path: "/auto-score", label: t("sidebar.autoScore"), disabled: false },
|
key: item.key,
|
||||||
{
|
path: item.path,
|
||||||
key: "reward-settings",
|
label: t(item.labelKey),
|
||||||
path: "/reward-settings",
|
disabled: Boolean(item.adminOnly) && !canAdmin,
|
||||||
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 },
|
|
||||||
],
|
|
||||||
[canAdmin, t]
|
[canAdmin, t]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -181,7 +184,13 @@ 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(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 || "")
|
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" })
|
||||||
}
|
}
|
||||||
@@ -211,6 +220,15 @@ 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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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 = [
|
const tabItems = [
|
||||||
{
|
{
|
||||||
key: "appearance",
|
key: "appearance",
|
||||||
@@ -745,6 +775,28 @@ export const Settings: React.FC<{
|
|||||||
{t("settings.zoomHint")}
|
{t("settings.zoomHint")}
|
||||||
</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>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"pleaseSelect": "Please select",
|
"pleaseSelect": "Please select",
|
||||||
"pleaseEnter": "Please enter",
|
"pleaseEnter": "Please enter",
|
||||||
"readOnly": "Read-only mode",
|
"readOnly": "Read-only mode",
|
||||||
"none": "None"
|
"none": "None",
|
||||||
|
"more": "More"
|
||||||
},
|
},
|
||||||
"oobe": {
|
"oobe": {
|
||||||
"title": "Welcome to SecScore",
|
"title": "Welcome to SecScore",
|
||||||
@@ -161,6 +162,13 @@
|
|||||||
"saveFailed": "Save failed"
|
"saveFailed": "Save failed"
|
||||||
},
|
},
|
||||||
"zoomHint": "Adjust the overall size of the application interface",
|
"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",
|
"zoomUpdated": "Interface zoom updated",
|
||||||
"updateFailed": "Update failed",
|
"updateFailed": "Update failed",
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"pleaseSelect": "请选择",
|
"pleaseSelect": "请选择",
|
||||||
"pleaseEnter": "请输入",
|
"pleaseEnter": "请输入",
|
||||||
"readOnly": "当前为只读权限",
|
"readOnly": "当前为只读权限",
|
||||||
"none": "无"
|
"none": "无",
|
||||||
|
"more": "更多"
|
||||||
},
|
},
|
||||||
"oobe": {
|
"oobe": {
|
||||||
"title": "欢迎使用 SecScore",
|
"title": "欢迎使用 SecScore",
|
||||||
@@ -161,6 +162,13 @@
|
|||||||
"saveFailed": "保存失败"
|
"saveFailed": "保存失败"
|
||||||
},
|
},
|
||||||
"zoomHint": "调节应用界面的整体大小",
|
"zoomHint": "调节应用界面的整体大小",
|
||||||
|
"mobile": {
|
||||||
|
"navigationTitle": "页面入口",
|
||||||
|
"bottomNav": {
|
||||||
|
"label": "底部导航功能",
|
||||||
|
"hint": "可选择要出现在手机底部导航的功能。底栏最多展示 4 项,其余会自动收纳到“更多”。"
|
||||||
|
}
|
||||||
|
},
|
||||||
"zoomUpdated": "界面缩放已更新",
|
"zoomUpdated": "界面缩放已更新",
|
||||||
"updateFailed": "更新失败",
|
"updateFailed": "更新失败",
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export type settingsKey =
|
|||||||
| "dashboards_config"
|
| "dashboards_config"
|
||||||
| "pg_connection_string"
|
| "pg_connection_string"
|
||||||
| "pg_connection_status"
|
| "pg_connection_status"
|
||||||
|
| "mobile_bottom_nav_items"
|
||||||
|
|
||||||
export interface settingsSpec {
|
export interface settingsSpec {
|
||||||
is_wizard_completed: boolean
|
is_wizard_completed: boolean
|
||||||
@@ -53,6 +54,7 @@ export interface settingsSpec {
|
|||||||
type: "sqlite" | "postgresql"
|
type: "sqlite" | "postgresql"
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
mobile_bottom_nav_items: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = {
|
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: {
|
server: {
|
||||||
host: process.env.TAURI_DEV_HOST || false,
|
host: process.env.TAURI_DEV_HOST || false,
|
||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: false,
|
||||||
hmr: process.env.TAURI_DEV_HOST
|
hmr: process.env.TAURI_DEV_HOST
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
|
|||||||
Reference in New Issue
Block a user