poller.rs 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. use crate::api::{models::{CfcTask, WaterRecord}, ApiClient};
  2. use crate::app_state::AppState;
  3. use crate::config::PetConfig;
  4. use crate::state::{map_state, PetState, PetStatePayload};
  5. use chrono::Timelike;
  6. use std::sync::Arc;
  7. use std::time::Duration;
  8. use tauri::{AppHandle, Emitter, Manager};
  9. /// 启动轮询任务:启动即同步吉祥物+喝水检查,然后 30s 任务 / 60s 健康 / 1h 喝水。
  10. /// 返回 JoinHandle 以便 save_settings 时 abort 重启。
  11. pub fn spawn(app: AppHandle, api: Arc<ApiClient>) -> tauri::async_runtime::JoinHandle<()> {
  12. tauri::async_runtime::spawn(async move {
  13. sync_mascot(&app, &api).await;
  14. check_water(&app, &api).await;
  15. let mut tasks_iv = tokio::time::interval(Duration::from_secs(30));
  16. let mut health_iv = tokio::time::interval(Duration::from_secs(60));
  17. let mut water_iv = tokio::time::interval(Duration::from_secs(3600));
  18. loop {
  19. tokio::select! {
  20. _ = tasks_iv.tick() => poll_tasks(&app, &api).await,
  21. _ = health_iv.tick() => poll_health(&app, &api).await,
  22. _ = water_iv.tick() => check_water(&app, &api).await,
  23. }
  24. }
  25. })
  26. }
  27. fn in_active_hour(hour: u32) -> bool {
  28. (10..=20).contains(&hour)
  29. }
  30. async fn sync_mascot(app: &AppHandle, api: &ApiClient) {
  31. let Ok(mascot) = api.mascot_get().await else { return };
  32. let pet_name = mascot.mascot.as_deref().unwrap_or("xibao");
  33. let path = format!("DefaultPets/{pet_name}/pet-config.json");
  34. // 优先读嵌入资产;失败回退 resource_dir
  35. let bytes = app
  36. .asset_resolver()
  37. .get(path.clone())
  38. .map(|a| a.bytes)
  39. .or_else(|| std::fs::read(app.path().resource_dir().unwrap_or_default().join(&path)).ok());
  40. let Some(bytes) = bytes else { return };
  41. if let Ok(json) = String::from_utf8(bytes) {
  42. if let Ok(cfg) = PetConfig::parse(&json) {
  43. if let Ok(payload) = serde_json::to_string(&cfg) {
  44. let _ = app.emit("pet://config", payload);
  45. }
  46. }
  47. }
  48. }
  49. async fn poll_tasks(app: &AppHandle, api: &ApiClient) {
  50. match api.tasks_today().await {
  51. Ok(tasks) => {
  52. let state = map_state(Some(&tasks), None, false, false);
  53. let payload = PetStatePayload { state, reason: Some("tasks".into()), data: None };
  54. let _ = app.emit("pet://state", payload);
  55. // 选出最紧急的待办任务作为「点提醒即完成」的目标
  56. let urgent = pick_urgent_task(&tasks);
  57. let new_id = urgent.as_ref().map(|t| t.id);
  58. let changed = match app.try_state::<AppState>() {
  59. Some(s) => {
  60. let mut cur = s.pending_task.lock().unwrap();
  61. let changed = *cur != new_id;
  62. *cur = new_id;
  63. changed
  64. }
  65. None => false,
  66. };
  67. if changed {
  68. if let Some(t) = &urgent {
  69. let title = t.title.clone().unwrap_or_else(|| "待办任务".into());
  70. let _ = app.emit(
  71. "pet://bubble",
  72. serde_json::json!({
  73. "text": format!("「{title}」快到点了,点我完成!"),
  74. "durationMs": 15000
  75. }),
  76. );
  77. }
  78. }
  79. }
  80. Err(_) => {
  81. let _ = app.emit(
  82. "pet://bubble",
  83. serde_json::json!({"text": "连接后端失败,请检查服务", "durationMs": 5000}),
  84. );
  85. }
  86. }
  87. }
  88. /// 从今日任务中选出最紧急、可一键完成的待办任务(未完成且已到期/今日到期)
  89. fn pick_urgent_task(tasks: &[CfcTask]) -> Option<CfcTask> {
  90. let today = chrono::Local::now().date_naive();
  91. tasks
  92. .iter()
  93. .filter(|t| matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS")))
  94. .filter(|t| {
  95. t.deadline
  96. .as_ref()
  97. .and_then(|d| d.to_local_date())
  98. .map(|due| due <= today)
  99. .unwrap_or(false)
  100. })
  101. .min_by_key(|t| t.deadline.as_ref().and_then(|d| d.to_local_date()))
  102. .cloned()
  103. }
  104. async fn poll_health(app: &AppHandle, api: &ApiClient) {
  105. match (api.energy_overview().await, api.water_list().await) {
  106. (Ok(energy), Ok(water)) => {
  107. let done = water_done_today(&water);
  108. let state = map_state(None, Some(&energy), done, false);
  109. let payload = PetStatePayload { state, reason: Some("health".into()), data: None };
  110. let _ = app.emit("pet://state", payload);
  111. }
  112. _ => { /* 静默,与现版一致 */ }
  113. }
  114. }
  115. async fn check_water(app: &AppHandle, api: &ApiClient) {
  116. let hour = chrono::Local::now().hour();
  117. if !in_active_hour(hour) {
  118. return;
  119. }
  120. let water = match api.water_list().await {
  121. Ok(w) => w,
  122. Err(_) => return,
  123. };
  124. if !water_done_today(&water) {
  125. let payload = PetStatePayload {
  126. state: PetState::WaitingForInteraction,
  127. reason: Some("drink_water".into()),
  128. data: None,
  129. };
  130. let _ = app.emit("pet://state", payload);
  131. let _ = app.emit(
  132. "pet://bubble",
  133. serde_json::json!({"text": "该喝水啦~点我确认完成!", "durationMs": 15000}),
  134. );
  135. }
  136. }
  137. /// 今天是否已有喝水记录(drunkAt 优先,回退 createdAt)
  138. fn water_done_today(records: &[WaterRecord]) -> bool {
  139. let today = chrono::Local::now().date_naive();
  140. records.iter().any(|r| {
  141. r.drunk_at
  142. .as_ref()
  143. .or(r.created_at.as_ref())
  144. .and_then(|d| d.to_local_date())
  145. .map(|d| d == today)
  146. .unwrap_or(false)
  147. })
  148. }
  149. #[cfg(test)]
  150. mod tests {
  151. use super::*;
  152. use crate::api::models::FlexDate;
  153. #[test]
  154. fn active_hour_window_is_10_to_20_inclusive() {
  155. assert!(!in_active_hour(9));
  156. assert!(in_active_hour(10));
  157. assert!(in_active_hour(15));
  158. assert!(in_active_hour(20));
  159. assert!(!in_active_hour(21));
  160. }
  161. fn water(drunk_at: Option<&str>, created_at: Option<&str>) -> WaterRecord {
  162. WaterRecord {
  163. id: None,
  164. member_id: None,
  165. checkin_id: None,
  166. amount_ml: None,
  167. water_type: None,
  168. drunk_at: drunk_at.map(|s| FlexDate::Iso(s.to_string())),
  169. created_at: created_at.map(|s| FlexDate::Iso(s.to_string())),
  170. }
  171. }
  172. #[test]
  173. fn water_done_today_matches_today_only() {
  174. let today = chrono::Local::now().format("%Y-%m-%d").to_string();
  175. let today_dt = format!("{today}T08:00:00");
  176. assert!(water_done_today(&[water(Some(&today_dt), None)]));
  177. assert!(!water_done_today(&[water(Some("2000-01-01T08:00:00"), None)]));
  178. assert!(!water_done_today(&[]));
  179. }
  180. #[test]
  181. fn water_done_today_falls_back_to_created_at() {
  182. let today = chrono::Local::now().format("%Y-%m-%d").to_string();
  183. let today_dt = format!("{today}T09:30:00");
  184. assert!(water_done_today(&[water(None, Some(&today_dt))]));
  185. }
  186. fn task(id: i64, status: &str, due: Option<&str>) -> CfcTask {
  187. CfcTask {
  188. id,
  189. title: None,
  190. status: Some(status.to_string()),
  191. deadline: due.map(|s| FlexDate::Iso(s.to_string())),
  192. }
  193. }
  194. #[test]
  195. fn pick_urgent_prefers_earliest_due_pending() {
  196. let today = chrono::Local::now().format("%Y-%m-%d").to_string();
  197. let tasks = vec![
  198. task(1, "COMPLETED", Some("2000-01-01")),
  199. task(2, "PENDING", Some(&today)),
  200. task(3, "PENDING", Some("2000-01-01")),
  201. ];
  202. assert_eq!(pick_urgent_task(&tasks).unwrap().id, 3);
  203. }
  204. #[test]
  205. fn pick_urgent_none_when_not_yet_due() {
  206. let tasks = vec![task(1, "PENDING", Some("2099-12-31"))];
  207. assert!(pick_urgent_task(&tasks).is_none());
  208. }
  209. #[test]
  210. fn pick_urgent_none_when_no_deadline() {
  211. let tasks = vec![task(1, "PENDING", None)];
  212. assert!(pick_urgent_task(&tasks).is_none());
  213. }
  214. }