feat: 支持移动端底部导航自定义与更多收纳

This commit is contained in:
JSR
2026-03-28 10:01:01 +08:00
parent 914eefa56a
commit 4af6157e0d
11 changed files with 685 additions and 197 deletions
+202 -114
View File
@@ -828,139 +828,227 @@ async fn db_sync_apply_internal(
};
let local_students = load_students(&local_conn).await?;
let remote_students = load_students(&remote_conn).await?;
let local_reasons = load_reasons(&local_conn).await?;
let remote_reasons = load_reasons(&remote_conn).await?;
let local_tags = load_tags(&local_conn).await?;
let remote_tags = load_tags(&remote_conn).await?;
let local_events = load_events(&local_conn).await?;
let remote_events = load_events(&remote_conn).await?;
let local_reward_settings = load_reward_settings(&local_conn).await?;
let remote_reward_settings = load_reward_settings(&remote_conn).await?;
let local_reward_redemptions = load_reward_redemptions(&local_conn).await?;
let remote_reward_redemptions = load_reward_redemptions(&remote_conn).await?;
let local_pairs = load_student_tag_pairs(&local_conn).await?;
let remote_pairs = load_student_tag_pairs(&remote_conn).await?;
let (preferred, target) = if strategy == ConflictStrategy::KeepLocal {
(&local_conn, &remote_conn)
} else {
(&remote_conn, &local_conn)
};
let mut synced_records = 0usize;
let mut resolved_conflicts = 0usize;
for student in local_students.values() {
if upsert_student(&remote_conn, student).await? {
synced_records += 1;
}
}
let remote_students_after = load_students(&remote_conn).await?;
for student in remote_students_after.values() {
if upsert_student(&local_conn, student).await? {
synced_records += 1;
}
}
for reason in local_reasons.values() {
if upsert_reason(&remote_conn, reason).await? {
synced_records += 1;
}
}
let remote_reasons_after = load_reasons(&remote_conn).await?;
for reason in remote_reasons_after.values() {
if upsert_reason(&local_conn, reason).await? {
synced_records += 1;
}
}
for tag in local_tags.values() {
if upsert_tag(&remote_conn, tag).await? {
synced_records += 1;
}
}
let remote_tags_after = load_tags(&remote_conn).await?;
for tag in remote_tags_after.values() {
if upsert_tag(&local_conn, tag).await? {
synced_records += 1;
}
}
for event in local_events.values() {
if upsert_event(&remote_conn, event).await? {
synced_records += 1;
}
}
let remote_events_after = load_events(&remote_conn).await?;
for event in remote_events_after.values() {
if upsert_event(&local_conn, event).await? {
synced_records += 1;
}
}
for reward in local_reward_settings.values() {
if upsert_reward_setting(&remote_conn, reward).await? {
synced_records += 1;
}
}
let remote_reward_settings_after = load_reward_settings(&remote_conn).await?;
for reward in remote_reward_settings_after.values() {
if upsert_reward_setting(&local_conn, reward).await? {
synced_records += 1;
}
}
for redemption in local_reward_redemptions.values() {
if upsert_reward_redemption(&remote_conn, redemption).await? {
synced_records += 1;
}
}
let remote_reward_redemptions_after = load_reward_redemptions(&remote_conn).await?;
for redemption in remote_reward_redemptions_after.values() {
if upsert_reward_redemption(&local_conn, redemption).await? {
synced_records += 1;
// Deterministic per-key merge:
// 1) local_only => remote
// 2) remote_only => local
// 3) conflict => resolve by strategy (winner -> loser)
let student_keys: std::collections::HashSet<String> = local_students
.keys()
.chain(remote_students.keys())
.cloned()
.collect();
for key in student_keys {
match (local_students.get(&key), remote_students.get(&key)) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_student(&remote_conn, local).await?
} else {
upsert_student(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_student(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_student(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
for pair in local_pairs.union(&remote_pairs) {
if ensure_student_tag_pair(&local_conn, pair).await? {
synced_records += 1;
let reason_keys: std::collections::HashSet<String> = local_reasons
.keys()
.chain(remote_reasons.keys())
.cloned()
.collect();
for key in reason_keys {
match (local_reasons.get(&key), remote_reasons.get(&key)) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_reason(&remote_conn, local).await?
} else {
upsert_reason(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_reason(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_reason(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
let tag_keys: std::collections::HashSet<String> = local_tags
.keys()
.chain(remote_tags.keys())
.cloned()
.collect();
for key in tag_keys {
match (local_tags.get(&key), remote_tags.get(&key)) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_tag(&remote_conn, local).await?
} else {
upsert_tag(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_tag(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_tag(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
let event_keys: std::collections::HashSet<String> = local_events
.keys()
.chain(remote_events.keys())
.cloned()
.collect();
for key in event_keys {
match (local_events.get(&key), remote_events.get(&key)) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_event(&remote_conn, local).await?
} else {
upsert_event(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_event(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_event(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
let reward_setting_keys: std::collections::HashSet<String> = local_reward_settings
.keys()
.chain(remote_reward_settings.keys())
.cloned()
.collect();
for key in reward_setting_keys {
match (
local_reward_settings.get(&key),
remote_reward_settings.get(&key),
) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_reward_setting(&remote_conn, local).await?
} else {
upsert_reward_setting(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_reward_setting(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_reward_setting(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
let redemption_keys: std::collections::HashSet<String> = local_reward_redemptions
.keys()
.chain(remote_reward_redemptions.keys())
.cloned()
.collect();
for key in redemption_keys {
match (
local_reward_redemptions.get(&key),
remote_reward_redemptions.get(&key),
) {
(Some(local), Some(remote)) if local != remote => {
let changed = if strategy == ConflictStrategy::KeepLocal {
upsert_reward_redemption(&remote_conn, local).await?
} else {
upsert_reward_redemption(&local_conn, remote).await?
};
if changed {
resolved_conflicts += 1;
}
}
(Some(local), None) => {
if upsert_reward_redemption(&remote_conn, local).await? {
synced_records += 1;
}
}
(None, Some(remote)) => {
if upsert_reward_redemption(&local_conn, remote).await? {
synced_records += 1;
}
}
_ => {}
}
}
for pair in local_pairs.difference(&remote_pairs) {
if ensure_student_tag_pair(&remote_conn, pair).await? {
synced_records += 1;
}
}
let preferred_students = load_students(preferred).await?;
for student in preferred_students.values() {
if upsert_student(target, student).await? {
resolved_conflicts += 1;
}
}
let preferred_reasons = load_reasons(preferred).await?;
for reason in preferred_reasons.values() {
if upsert_reason(target, reason).await? {
resolved_conflicts += 1;
}
}
let preferred_tags = load_tags(preferred).await?;
for tag in preferred_tags.values() {
if upsert_tag(target, tag).await? {
resolved_conflicts += 1;
}
}
let preferred_events = load_events(preferred).await?;
for event in preferred_events.values() {
if upsert_event(target, event).await? {
resolved_conflicts += 1;
}
}
let preferred_reward_settings = load_reward_settings(preferred).await?;
for reward in preferred_reward_settings.values() {
if upsert_reward_setting(target, reward).await? {
resolved_conflicts += 1;
}
}
let preferred_reward_redemptions = load_reward_redemptions(preferred).await?;
for redemption in preferred_reward_redemptions.values() {
if upsert_reward_redemption(target, redemption).await? {
resolved_conflicts += 1;
}
}
let preferred_pairs = load_student_tag_pairs(preferred).await?;
for pair in &preferred_pairs {
if ensure_student_tag_pair(target, pair).await? {
resolved_conflicts += 1;
for pair in remote_pairs.difference(&local_pairs) {
if ensure_student_tag_pair(&local_conn, pair).await? {
synced_records += 1;
}
}
+73
View File
@@ -17,6 +17,7 @@ pub struct SettingsSpec {
pub dashboards_config: JsonValue,
pub pg_connection_string: String,
pub pg_connection_status: JsonValue,
pub mobile_bottom_nav_items: JsonValue,
}
impl Default for SettingsSpec {
@@ -34,6 +35,18 @@ impl Default for SettingsSpec {
dashboards_config: JsonValue::Array(vec![]),
pg_connection_string: String::new(),
pg_connection_status: serde_json::json!({"connected": false, "type": "sqlite"}),
mobile_bottom_nav_items: serde_json::json!([
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings"
]),
}
}
}
@@ -52,6 +65,7 @@ pub enum SettingsKey {
DashboardsConfig,
PgConnectionString,
PgConnectionStatus,
MobileBottomNavItems,
}
impl SettingsKey {
@@ -69,6 +83,7 @@ impl SettingsKey {
SettingsKey::DashboardsConfig => "dashboards_config",
SettingsKey::PgConnectionString => "pg_connection_string",
SettingsKey::PgConnectionStatus => "pg_connection_status",
SettingsKey::MobileBottomNavItems => "mobile_bottom_nav_items",
}
}
@@ -86,6 +101,7 @@ impl SettingsKey {
"dashboards_config" => Some(SettingsKey::DashboardsConfig),
"pg_connection_string" => Some(SettingsKey::PgConnectionString),
"pg_connection_status" => Some(SettingsKey::PgConnectionStatus),
"mobile_bottom_nav_items" => Some(SettingsKey::MobileBottomNavItems),
_ => None,
}
}
@@ -383,6 +399,48 @@ impl SettingsService {
},
);
defs.insert(
SettingsKey::MobileBottomNavItems,
SettingDefinition {
kind: SettingValueKind::Json,
default_value: SettingsValue::Json(serde_json::json!([
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings"
])),
write_permission: PermissionRequirement::Admin,
validate: Some(|v| {
if let SettingsValue::Json(JsonValue::Array(items)) = v {
let allowed = [
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings",
];
return items.iter().all(|item| {
item.as_str()
.map(|s| allowed.contains(&s))
.unwrap_or(false)
});
}
false
}),
},
);
defs
}
@@ -521,6 +579,21 @@ impl SettingsService {
SettingsValue::Json(j) => j,
_ => serde_json::json!({"connected": false, "type": "sqlite"}),
},
mobile_bottom_nav_items: match self.get_value(SettingsKey::MobileBottomNavItems) {
SettingsValue::Json(j) => j,
_ => serde_json::json!([
"home",
"students",
"score",
"auto-score",
"reward-settings",
"boards",
"leaderboard",
"settlements",
"reasons",
"settings"
]),
},
}
}