| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236 |
- use crate::api::{models::{CfcTask, WaterRecord}, ApiClient};
- use crate::app_state::AppState;
- 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.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, false, false);
- let payload = PetStatePayload { state, reason: Some("tasks".into()), data: None };
- let _ = app.emit("pet://state", payload);
- // 选出最紧急的待办任务作为「点提醒即完成」的目标
- let urgent = pick_urgent_task(&tasks);
- let new_id = urgent.as_ref().map(|t| t.id);
- let changed = match app.try_state::<AppState>() {
- Some(s) => {
- let mut cur = s.pending_task.lock().unwrap();
- let changed = *cur != new_id;
- *cur = new_id;
- changed
- }
- None => false,
- };
- if changed {
- if let Some(t) = &urgent {
- let title = t.title.clone().unwrap_or_else(|| "待办任务".into());
- let _ = app.emit(
- "pet://bubble",
- serde_json::json!({
- "text": format!("「{title}」快到点了,点我完成!"),
- "durationMs": 15000
- }),
- );
- }
- }
- }
- Err(_) => {
- let _ = app.emit(
- "pet://bubble",
- serde_json::json!({"text": "连接后端失败,请检查服务", "durationMs": 5000}),
- );
- }
- }
- }
- /// 从今日任务中选出最紧急、可一键完成的待办任务(未完成且已到期/今日到期)
- fn pick_urgent_task(tasks: &[CfcTask]) -> Option<CfcTask> {
- let today = chrono::Local::now().date_naive();
- tasks
- .iter()
- .filter(|t| matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS")))
- .filter(|t| {
- t.deadline
- .as_ref()
- .and_then(|d| d.to_local_date())
- .map(|due| due <= today)
- .unwrap_or(false)
- })
- .min_by_key(|t| t.deadline.as_ref().and_then(|d| d.to_local_date()))
- .cloned()
- }
- async fn poll_health(app: &AppHandle, api: &ApiClient) {
- match (api.energy_overview().await, api.water_list().await) {
- (Ok(energy), Ok(water)) => {
- let done = water_done_today(&water);
- let state = map_state(None, Some(&energy), done, 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 water = match api.water_list().await {
- Ok(w) => w,
- Err(_) => return,
- };
- if !water_done_today(&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}),
- );
- }
- }
- /// 今天是否已有喝水记录(drunkAt 优先,回退 createdAt)
- fn water_done_today(records: &[WaterRecord]) -> bool {
- let today = chrono::Local::now().date_naive();
- records.iter().any(|r| {
- r.drunk_at
- .as_ref()
- .or(r.created_at.as_ref())
- .and_then(|d| d.to_local_date())
- .map(|d| d == today)
- .unwrap_or(false)
- })
- }
- #[cfg(test)]
- mod tests {
- use super::*;
- use crate::api::models::FlexDate;
- #[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));
- }
- fn water(drunk_at: Option<&str>, created_at: Option<&str>) -> WaterRecord {
- WaterRecord {
- id: None,
- member_id: None,
- checkin_id: None,
- amount_ml: None,
- water_type: None,
- drunk_at: drunk_at.map(|s| FlexDate::Iso(s.to_string())),
- created_at: created_at.map(|s| FlexDate::Iso(s.to_string())),
- }
- }
- #[test]
- fn water_done_today_matches_today_only() {
- let today = chrono::Local::now().format("%Y-%m-%d").to_string();
- let today_dt = format!("{today}T08:00:00");
- assert!(water_done_today(&[water(Some(&today_dt), None)]));
- assert!(!water_done_today(&[water(Some("2000-01-01T08:00:00"), None)]));
- assert!(!water_done_today(&[]));
- }
- #[test]
- fn water_done_today_falls_back_to_created_at() {
- let today = chrono::Local::now().format("%Y-%m-%d").to_string();
- let today_dt = format!("{today}T09:30:00");
- assert!(water_done_today(&[water(None, Some(&today_dt))]));
- }
- fn task(id: i64, status: &str, due: Option<&str>) -> CfcTask {
- CfcTask {
- id,
- title: None,
- status: Some(status.to_string()),
- deadline: due.map(|s| FlexDate::Iso(s.to_string())),
- }
- }
- #[test]
- fn pick_urgent_prefers_earliest_due_pending() {
- let today = chrono::Local::now().format("%Y-%m-%d").to_string();
- let tasks = vec![
- task(1, "COMPLETED", Some("2000-01-01")),
- task(2, "PENDING", Some(&today)),
- task(3, "PENDING", Some("2000-01-01")),
- ];
- assert_eq!(pick_urgent_task(&tasks).unwrap().id, 3);
- }
- #[test]
- fn pick_urgent_none_when_not_yet_due() {
- let tasks = vec![task(1, "PENDING", Some("2099-12-31"))];
- assert!(pick_urgent_task(&tasks).is_none());
- }
- #[test]
- fn pick_urgent_none_when_no_deadline() {
- let tasks = vec![task(1, "PENDING", None)];
- assert!(pick_urgent_task(&tasks).is_none());
- }
- }
|