Quellcode durchsuchen

fix(desktop-pet): 透明无边框/右键菜单/任务栏图标/默认接口/点击完成待办

- tauri.conf.json: shadow=false 去除残留边框, skipTaskbar=false 显示任务栏图标

- index.html/interactions.js: 背景透明 + 右键菜单(设置/鼠标穿透/退出)

- commands.rs/main.rs: open_settings/quit_app 命令; 点击宠物优先完成待办任务

- poller.rs: 到期任务提醒并记录可一键完成的 pending_task

- settings.rs/settings.html: 默认接口地址改为 https://cfc.cf-club.com

- api/mod.rs: complete_task 封装
liaoxg vor 1 Tag
Ursprung
Commit
bc192f6618

+ 5 - 0
cfc-desktop-pet/src-tauri/src/api/mod.rs

@@ -100,6 +100,11 @@ impl ApiClient {
     pub async fn mascot_get(&self) -> Result<MascotInfo, ApiError> {
         self.post("/api/user/mascot/get", None).await
     }
+
+    /// 一键完成任务(memberId 由后端从 JWT 解析)
+    pub async fn complete_task(&self, task_id: i64) -> Result<serde_json::Value, ApiError> {
+        self.post(&format!("/api/tasks/{task_id}/complete"), Some(&serde_json::json!({}))).await
+    }
 }
 
 #[cfg(test)]

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

@@ -4,5 +4,7 @@ use std::sync::{Arc, Mutex};
 pub struct AppState {
     pub passthrough: Mutex<bool>,
     pub poll_handle: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
+    /// 当前提醒中、可一键完成的待办任务 id(点击宠物即完成)
+    pub pending_task: Mutex<Option<i64>>,
     pub api: Arc<ApiClient>,
 }

+ 49 - 1
cfc-desktop-pet/src-tauri/src/commands.rs

@@ -8,7 +8,16 @@ use tauri::{AppHandle, Emitter, Manager, State};
 #[tauri::command]
 pub async fn on_interact(app: AppHandle, action: String, _data: Option<String>) -> Result<(), String> {
     match action.as_str() {
-        "click" => complete_water(&app).await,
+        "click" => {
+            // 有可一键完成的待办任务时优先完成它,否则回退到喝水打卡
+            let pending = app
+                .try_state::<AppState>()
+                .and_then(|s| *s.pending_task.lock().unwrap());
+            match pending {
+                Some(id) => complete_task_by_id(&app, id).await,
+                None => complete_water(&app).await,
+            }
+        }
         "pet" => {
             let _ = app.emit("pet://bubble", serde_json::json!({"text": "嘻嘻~", "durationMs": 2000}));
             Ok(())
@@ -30,6 +39,21 @@ pub async fn drag_end() -> Result<(), String> {
     Ok(())
 }
 
+#[tauri::command]
+pub fn open_settings(app: AppHandle) -> Result<(), String> {
+    if let Some(win) = app.get_webview_window("settings") {
+        let _ = win.show();
+        let _ = win.set_focus();
+    }
+    Ok(())
+}
+
+#[tauri::command]
+pub fn quit_app(app: AppHandle) -> Result<(), String> {
+    app.exit(0);
+    Ok(())
+}
+
 #[tauri::command]
 pub fn get_settings(
     _state: State<AppState>,
@@ -102,3 +126,27 @@ async fn complete_water(app: &AppHandle) -> Result<(), String> {
         }
     }
 }
+
+async fn complete_task_by_id(app: &AppHandle, task_id: i64) -> Result<(), String> {
+    let state = app.state::<AppState>();
+    let api = state.api.clone();
+    match api.complete_task(task_id).await {
+        Ok(_) => {
+            if let Some(s) = app.try_state::<AppState>() {
+                *s.pending_task.lock().unwrap() = None;
+            }
+            let payload = PetStatePayload {
+                state: PetState::Happy,
+                reason: Some("task_complete".into()),
+                data: None,
+            };
+            let _ = app.emit("pet://state", payload);
+            let _ = app.emit("pet://bubble", serde_json::json!({"text": "任务完成!太棒了~", "durationMs": 4000}));
+            Ok(())
+        }
+        Err(_) => {
+            let _ = app.emit("pet://bubble", serde_json::json!({"text": "完成任务失败,请稍后再试", "durationMs": 4000}));
+            Err("task complete failed".into())
+        }
+    }
+}

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

@@ -35,6 +35,7 @@ fn main() {
             app.manage(AppState {
                 passthrough: std::sync::Mutex::new(false),
                 poll_handle: std::sync::Mutex::new(Some(handle)),
+                pending_task: std::sync::Mutex::new(None),
                 api,
             });
             tray::setup_tray(app)?;
@@ -46,6 +47,8 @@ fn main() {
             commands::drag_end,
             commands::get_settings,
             commands::save_settings,
+            commands::open_settings,
+            commands::quit_app,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

+ 76 - 1
cfc-desktop-pet/src-tauri/src/poller.rs

@@ -1,4 +1,5 @@
-use crate::api::{models::WaterRecord, ApiClient};
+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;
@@ -59,6 +60,31 @@ async fn poll_tasks(app: &AppHandle, api: &ApiClient) {
             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(
@@ -69,6 +95,23 @@ async fn poll_tasks(app: &AppHandle, api: &ApiClient) {
     }
 }
 
+/// 从今日任务中选出最紧急、可一键完成的待办任务(未完成且已到期/今日到期)
+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)) => {
@@ -158,4 +201,36 @@ mod tests {
         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());
+    }
 }

+ 3 - 3
cfc-desktop-pet/src-tauri/src/settings.rs

@@ -16,7 +16,7 @@ impl Default for AppSettings {
 }
 
 fn default_base_url() -> String {
-    "http://localhost:9082".to_string()
+    "https://cfc.cf-club.com".to_string()
 }
 
 pub struct SettingsStore {
@@ -63,7 +63,7 @@ mod tests {
     fn default_when_file_missing() {
         let store = SettingsStore::new(temp_dir());
         let cfg = store.load();
-        assert_eq!(cfg.base_url, "http://localhost:9082");
+        assert_eq!(cfg.base_url, "https://cfc.cf-club.com");
     }
 
     #[test]
@@ -73,7 +73,7 @@ mod tests {
         fs::write(dir.join("settings.json"), "not json").unwrap();
         let store = SettingsStore::new(dir);
         let cfg = store.load();
-        assert_eq!(cfg.base_url, "http://localhost:9082");
+        assert_eq!(cfg.base_url, "https://cfc.cf-club.com");
     }
 
     #[test]

+ 2 - 1
cfc-desktop-pet/src-tauri/tauri.conf.json

@@ -17,8 +17,9 @@
         "height": 200,
         "transparent": true,
         "decorations": false,
+        "shadow": false,
         "alwaysOnTop": true,
-        "skipTaskbar": true,
+        "skipTaskbar": false,
         "resizable": false
       },
       {

+ 47 - 9
cfc-desktop-pet/src/index.html

@@ -5,16 +5,47 @@
 <title>Xaf Pet</title>
 <link rel="stylesheet" href="animations.css" />
 <style>
-  html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: transparent; }
-  #pet-stage { position: fixed; inset: 0; display: flex; align-items: flex-end; justify-content: center; pointer-events: none; }
-  #pet { width: 120px; height: 140px; pointer-events: auto; cursor: grab; user-select: none; }
+  html, body {
+    width: 100%; height: 100%; margin: 0; overflow: hidden;
+    background: transparent !important;
+    -webkit-app-region: no-drag;
+  }
+  #pet-stage {
+    position: fixed; inset: 0; display: flex; align-items: flex-end;
+    justify-content: center; pointer-events: none;
+    background: transparent !important;
+  }
+  #pet {
+    width: 120px; height: 140px; pointer-events: auto;
+    cursor: grab; user-select: none;
+    -webkit-app-region: no-drag;
+  }
   #pet img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
-  #bubble { position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
-            background: #fff; border: 2px solid #4A9BD7; border-radius: 12px; padding: 6px 12px;
-            font: 13px/1.4 'PingFang SC', sans-serif; color: #333; white-space: nowrap;
-            display: none; box-shadow: 0 2px 8px rgba(0,0,0,.12); }
-  #bubble::after { content: ''; position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
-                   border: 6px solid transparent; border-top-color: #4A9BD7; }
+  #bubble {
+    position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
+    background: rgba(255,255,255,0.95); border: 2px solid #4A9BD7;
+    border-radius: 12px; padding: 6px 12px;
+    font: 13px/1.4 'PingFang SC', sans-serif; color: #333;
+    white-space: nowrap; display: none; box-shadow: 0 2px 8px rgba(0,0,0,.12);
+    pointer-events: none;
+  }
+  #bubble::after {
+    content: ''; position: absolute; top: 100%; left: 50%;
+    transform: translateX(-50%);
+    border: 6px solid transparent; border-top-color: #4A9BD7;
+  }
+  /* 右键菜单 */
+  #context-menu {
+    position: absolute; display: none;
+    background: #fff; border: 1px solid #ccc; border-radius: 6px;
+    box-shadow: 0 2px 12px rgba(0,0,0,.15); padding: 4px 0;
+    min-width: 120px; z-index: 9999; pointer-events: auto;
+  }
+  #context-menu .item {
+    padding: 6px 16px; font-size: 13px; cursor: pointer; color: #333;
+  }
+  #context-menu .item:hover { background: #f0f6ff; }
+  #context-menu .divider { height: 1px; background: #e0e0e0; margin: 4px 0; }
 </style>
 </head>
 <body>
@@ -24,6 +55,13 @@
     <div id="bubble"></div>
   </div>
 </div>
+<!-- 右键菜单 -->
+<div id="context-menu">
+  <div class="item" data-action="settings">设置</div>
+  <div class="item" data-action="passthrough">鼠标穿透</div>
+  <div class="divider"></div>
+  <div class="item" data-action="quit">退出</div>
+</div>
 <script src="pet-engine.js"></script>
 <script src="bridge.js"></script>
 <script src="interactions.js"></script>

+ 29 - 0
cfc-desktop-pet/src/interactions.js

@@ -2,11 +2,40 @@
   'use strict';
 
   const petEl = document.getElementById('pet');
+  const ctxMenu = document.getElementById('context-menu');
+  const invoke = window.__TAURI__?.core?.invoke;
 
   let dragStartX = 0, dragStartY = 0;
   let pressTimer = null;
   let isDragging = false;
 
+  // ── 右键菜单 ──────────────────────────────
+  petEl.addEventListener('contextmenu', function (e) {
+    e.preventDefault();
+    const x = Math.min(e.clientX, window.innerWidth - 130);
+    const y = Math.min(e.clientY, window.innerHeight - 100);
+    ctxMenu.style.left = x + 'px';
+    ctxMenu.style.top = y + 'px';
+    ctxMenu.style.display = 'block';
+  });
+
+  document.addEventListener('click', function (e) {
+    if (!ctxMenu.contains(e.target)) ctxMenu.style.display = 'none';
+  });
+  document.addEventListener('keydown', function (e) {
+    if (e.key === 'Escape') ctxMenu.style.display = 'none';
+  });
+
+ctxMenu.addEventListener('click', function (e) {
+    const item = e.target.closest('.item');
+    if (!item) return;
+    const action = item.dataset.action;
+    ctxMenu.style.display = 'none';
+    if (action === 'settings') invoke?.('open_settings');
+    else if (action === 'quit') { try { invoke?.('quit_app'); } catch (_) { window.close(); } }
+});
+
+  // ── 点击 / 长按 / 拖拽 ───────────────────
   petEl.addEventListener('mousedown', function (e) {
     dragStartX = e.clientX;
     dragStartY = e.clientY;

+ 2 - 2
cfc-desktop-pet/src/settings.html

@@ -15,7 +15,7 @@
 </head>
 <body>
   <label for="base-url">后端地址</label>
-  <input id="base-url" placeholder="http://localhost:9082" />
+  <input id="base-url" placeholder="https://cfc.cf-club.com" />
   <label for="token">令牌(留空则不修改)</label>
   <input id="token" type="password" placeholder="JWT Token" />
   <button id="save">保存</button>
@@ -29,7 +29,7 @@
       var statusEl = document.getElementById('status');
 
       invoke('get_settings').then(function (s) {
-        baseUrlEl.value = s.baseUrl || 'http://localhost:9082';
+        baseUrlEl.value = s.baseUrl || 'https://cfc.cf-club.com';
         statusEl.textContent = s.hasToken ? '已保存令牌' : '未设置令牌';
       }).catch(function () {});