Jelajahi Sumber

feat(desktop-pet): 轮询器(任务/能量/喝水)与 AppState

liaoxg 2 hari lalu
induk
melakukan
48211c941b

+ 8 - 0
cfc-desktop-pet/src-tauri/src/app_state.rs

@@ -0,0 +1,8 @@
+use crate::api::ApiClient;
+use std::sync::{Arc, Mutex};
+
+pub struct AppState {
+    pub passthrough: Mutex<bool>,
+    pub poll_handle: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
+    pub api: Arc<ApiClient>,
+}

+ 2 - 0
cfc-desktop-pet/src-tauri/src/main.rs

@@ -1,7 +1,9 @@
 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
 
 mod api;
+mod app_state;
 mod config;
+mod poller;
 mod settings;
 mod state;
 mod token;

+ 118 - 0
cfc-desktop-pet/src-tauri/src/poller.rs

@@ -0,0 +1,118 @@
+use crate::api::{models::DailyCheckin, ApiClient};
+use crate::config::PetConfig;
+use crate::state::{map_state, PetState, PetStatePayload};
+use chrono::Timelike;
+use std::sync::Arc;
+use std::time::Duration;
+use tauri::{AppHandle, Emitter, Manager};
+
+/// 启动轮询任务:启动即同步吉祥物+喝水检查,然后 30s 任务 / 60s 健康 / 1h 喝水。
+/// 返回 JoinHandle 以便 save_settings 时 abort 重启。
+pub fn spawn(app: AppHandle, api: Arc<ApiClient>) -> tauri::async_runtime::JoinHandle<()> {
+    tauri::async_runtime::spawn(async move {
+        sync_mascot(&app, &api).await;
+        check_water(&app, &api).await;
+
+        let mut tasks_iv = tokio::time::interval(Duration::from_secs(30));
+        let mut health_iv = tokio::time::interval(Duration::from_secs(60));
+        let mut water_iv = tokio::time::interval(Duration::from_secs(3600));
+
+        loop {
+            tokio::select! {
+                _ = tasks_iv.tick() => poll_tasks(&app, &api).await,
+                _ = health_iv.tick() => poll_health(&app, &api).await,
+                _ = water_iv.tick() => check_water(&app, &api).await,
+            }
+        }
+    })
+}
+
+fn in_active_hour(hour: u32) -> bool {
+    (10..=20).contains(&hour)
+}
+
+async fn sync_mascot(app: &AppHandle, api: &ApiClient) {
+    let Ok(mascot) = api.mascot_get().await else { return };
+    let pet_name = mascot.mascot_id.as_deref().unwrap_or("xibao");
+    let path = format!("DefaultPets/{pet_name}/pet-config.json");
+
+    // 优先读嵌入资产;失败回退 resource_dir
+    let bytes = app
+        .asset_resolver()
+        .get(path.clone())
+        .map(|a| a.bytes)
+        .or_else(|| std::fs::read(app.path().resource_dir().unwrap_or_default().join(&path)).ok());
+    let Some(bytes) = bytes else { return };
+
+    if let Ok(json) = String::from_utf8(bytes) {
+        if let Ok(cfg) = PetConfig::parse(&json) {
+            if let Ok(payload) = serde_json::to_string(&cfg) {
+                let _ = app.emit("pet://config", payload);
+            }
+        }
+    }
+}
+
+async fn poll_tasks(app: &AppHandle, api: &ApiClient) {
+    match api.tasks_today().await {
+        Ok(tasks) => {
+            let state = map_state(Some(&tasks), None, None, false);
+            let payload = PetStatePayload { state, reason: Some("tasks".into()), data: None };
+            let _ = app.emit("pet://state", payload);
+        }
+        Err(_) => {
+            let _ = app.emit(
+                "pet://bubble",
+                serde_json::json!({"text": "连接后端失败,请检查服务", "durationMs": 5000}),
+            );
+        }
+    }
+}
+
+async fn poll_health(app: &AppHandle, api: &ApiClient) {
+    match (api.energy_overview().await, api.daily_checkin().await) {
+        (Ok(energy), Ok(checkin)) => {
+            let state = map_state(None, Some(&energy), Some(&checkin), false);
+            let payload = PetStatePayload { state, reason: Some("health".into()), data: None };
+            let _ = app.emit("pet://state", payload);
+        }
+        _ => { /* 静默,与现版一致 */ }
+    }
+}
+
+async fn check_water(app: &AppHandle, api: &ApiClient) {
+    let hour = chrono::Local::now().hour();
+    if !in_active_hour(hour) {
+        return;
+    }
+    let checkin: DailyCheckin = match api.daily_checkin().await {
+        Ok(c) => c,
+        Err(_) => return,
+    };
+    if !checkin.items.iter().any(|i| i == "water") {
+        let payload = PetStatePayload {
+            state: PetState::WaitingForInteraction,
+            reason: Some("drink_water".into()),
+            data: None,
+        };
+        let _ = app.emit("pet://state", payload);
+        let _ = app.emit(
+            "pet://bubble",
+            serde_json::json!({"text": "该喝水啦~点我确认完成!", "durationMs": 15000}),
+        );
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn active_hour_window_is_10_to_20_inclusive() {
+        assert!(!in_active_hour(9));
+        assert!(in_active_hour(10));
+        assert!(in_active_hour(15));
+        assert!(in_active_hour(20));
+        assert!(!in_active_hour(21));
+    }
+}