Răsfoiți Sursa

fix(desktop-pet): 修复微信扫码登录 405、移除令牌输入框、悬停暂停活动与精细化动画

- api/mod.rs: normalize_base_url() 将非本机 http:// 升级为 https://,避免 nginx 301 使 reqwest 把 POST 降级为 GET 触发 405(新增 4 项单测)
- models.rs/commands.rs: 兼容旧版 qrconnect 后端(仅 {state,url} 无小程序码),给出明确「需部署最新后端」提示
- settings.html: 移除 JWT 令牌输入框及相关读写(保留 hasToken 探测)
- locomotion.rs: 新增 cursor_over_pet() 悬停检测,鼠标落在宠物身上时立即暂停走/跳/飞并保持静止(新增单测)
- index.html/animations.css/pet-pose.js: 姿态动画精细化(方向镜像 --face、走/跳/飞/站手更丰富关键帧、新增呼吸待机 pose-idle),并加固宠物 cursor:pointer
liaoxg 2 zile în urmă
părinte
comite
1db1196305

+ 59 - 2
cfc-desktop-pet/src-tauri/src/api/mod.rs

@@ -23,6 +23,31 @@ impl std::fmt::Display for ApiError {
     }
 }
 
+/// 规范化 base_url:非本机主机的 `http://` 一律升级为 `https://`。
+///
+/// 生产 `http://cfc.cf-club.com` 会被 nginx 以 301 重定向到 https,而 reqwest 在
+/// 301/302/303 上会把 POST 降级为 GET —— 后端对 GET 一律返回 405
+/// ("请求方法不支持,本平台接口统一使用POST")。直接升级协议头即可让 POST 原样直达,
+/// 从根上消除该 405;本机 / 内网地址保留 http,便于本地联调。
+pub fn normalize_base_url(raw: &str) -> String {
+    let t = raw.trim().trim_end_matches('/');
+    if let Some(rest) = t.strip_prefix("http://") {
+        let host_port = rest.split('/').next().unwrap_or("");
+        let host = host_port.split(':').next().unwrap_or("");
+        let is_local = host == "localhost"
+            || host == "127.0.0.1"
+            || host == "::1"
+            || host == "[::1]"
+            || host.starts_with("192.168.")
+            || host.starts_with("10.")
+            || host.starts_with("172.");
+        if !is_local {
+            return format!("https://{rest}");
+        }
+    }
+    t.to_string()
+}
+
 pub struct ApiClient {
     http: reqwest::Client,
     base_url: RwLock<String>,
@@ -33,7 +58,7 @@ impl ApiClient {
     pub fn new(base_url: String, token: String) -> Self {
         Self {
             http: reqwest::Client::new(),
-            base_url: RwLock::new(base_url.trim_end_matches('/').to_string()),
+            base_url: RwLock::new(normalize_base_url(&base_url)),
             token: RwLock::new(token),
         }
     }
@@ -43,7 +68,7 @@ impl ApiClient {
     }
 
     pub fn set_base_url(&self, base_url: String) {
-        *self.base_url.write().unwrap() = base_url.trim_end_matches('/').to_string();
+        *self.base_url.write().unwrap() = normalize_base_url(&base_url);
     }
 
     pub fn parse_response<T: DeserializeOwned>(&self, body: &str) -> Result<T, ApiError> {
@@ -185,4 +210,36 @@ mod tests {
         let client = ApiClient::new("http://x".into(), "t".into());
         assert!(matches!(client.parse_response::<CfcTask>(r#"{"code":200}"#), Err(ApiError::EmptyData)));
     }
+
+    #[test]
+    fn normalize_upgrades_public_http_to_https() {
+        assert_eq!(
+            normalize_base_url("http://cfc.cf-club.com"),
+            "https://cfc.cf-club.com"
+        );
+        assert_eq!(
+            normalize_base_url("http://cfc.cf-club.com/"),
+            "https://cfc.cf-club.com"
+        );
+        assert_eq!(
+            normalize_base_url("  http://cfc.cf-club.com  "),
+            "https://cfc.cf-club.com"
+        );
+        // 已是 https 保持不变
+        assert_eq!(
+            normalize_base_url("https://cfc.cf-club.com"),
+            "https://cfc.cf-club.com"
+        );
+    }
+
+    #[test]
+    fn normalize_keeps_local_and_private_http() {
+        assert_eq!(normalize_base_url("http://localhost:9082"), "http://localhost:9082");
+        assert_eq!(normalize_base_url("http://127.0.0.1:9082"), "http://127.0.0.1:9082");
+        assert_eq!(
+            normalize_base_url("http://192.168.16.251:9082"),
+            "http://192.168.16.251:9082"
+        );
+        assert_eq!(normalize_base_url("http://10.0.0.5"), "http://10.0.0.5");
+    }
 }

+ 16 - 0
cfc-desktop-pet/src-tauri/src/api/models.rs

@@ -118,6 +118,10 @@ pub struct WechatQrResp {
     pub state: String,
     #[serde(default)]
     pub qr_image: Option<String>,
+    /// 旧版后端(微信开放平台 qrconnect 网页扫码)返回的字段;新版小程序码契约下为 None。
+    /// 保留以便识别「服务端未升级」并给出明确提示,而不是静默展示空码。
+    #[serde(default)]
+    pub url: Option<String>,
 }
 
 /// POST /api/auth/wechat-web/poll → {status, token?, userId?, role?, nickname?, message?}
@@ -230,6 +234,18 @@ mod tests {
         let q = r.data.unwrap();
         assert_eq!(q.state, "abc123");
         assert_eq!(q.qr_image.as_deref(), Some(""));
+        assert!(q.url.is_none());
+    }
+
+    #[test]
+    fn parses_legacy_wechat_qr_response_with_url() {
+        // 旧版后端(qrconnect)只返回 {state,url},无 qrImage
+        let json = r#"{"code":200,"message":"success","data":{"state":"s1","url":"https://open.weixin.qq.com/connect/qrconnect?appid=&state=s1"}}"#;
+        let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
+        let q = r.data.unwrap();
+        assert_eq!(q.state, "s1");
+        assert!(q.qr_image.is_none());
+        assert!(q.url.as_deref().unwrap_or("").contains("qrconnect"));
     }
 
     #[test]

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

@@ -173,10 +173,24 @@ pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, Str
             .map_err(|e| format!("获取二维码失败: {e}"))?
     };
 
+    // 兼容旧版后端:只返回 {state,url}(微信开放平台 qrconnect 网页扫码)而无小程序码。
+    // 此时新版登录流程无法继续,明确报错,避免静默展示空码让用户困惑。
+    let qr_image = qr.qr_image.clone().unwrap_or_default();
+    if qr_image.is_empty() && qr.url.as_deref().map(|u| !u.is_empty()).unwrap_or(false) {
+        let _ = app.emit(
+            "pet://wechat-login",
+            serde_json::json!({
+                "status": "error",
+                "message": "服务端仍是旧版登录接口(微信开放平台 qrconnect),请部署最新后端(小程序码 /scan)后重试"
+            }),
+        );
+        return Err("服务端登录接口版本过旧:缺少小程序码,需部署最新后端".into());
+    }
+
     // 将小程序码图片推给设置窗口展示(用户用微信扫码后在小程序内确认登录)
     let _ = app.emit(
         "pet://wechat-qr",
-        serde_json::json!({ "state": qr.state.clone(), "qrImage": qr.qr_image.clone() }),
+        serde_json::json!({ "state": qr.state.clone(), "qrImage": qr_image }),
     );
 
     let api = app.state::<AppState>().api.clone();

+ 68 - 17
cfc-desktop-pet/src-tauri/src/locomotion.rs

@@ -20,6 +20,13 @@ const WIN_H: i32 = 200;
 const FEET_ANCHOR_X: i32 = 100;
 const FEET_ANCHOR_Y: i32 = 200;
 
+// ── 宠物可视包围盒(窗口内),用于「鼠标悬停在宠物身上 → 暂停活动」判定 ──
+// 与 index.html 的 #pet(120×140,底部居中)布局保持一致:left=(200-120)/2=40,top=200-140=60
+const PET_BOX_OFFSET_X: i32 = 40;
+const PET_BOX_OFFSET_Y: i32 = 60;
+const PET_BOX_W: i32 = 120;
+const PET_BOX_H: i32 = 140;
+
 // ── 抖动检测参数 ──
 const SHAKE_WINDOW_MS: u128 = 600;
 const SHAKE_MIN_REVERSALS: usize = 4;
@@ -86,6 +93,15 @@ pub fn clamp_window_pos(x: i32, y: i32, wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i
     (x.clamp(wa_x, max_x), y.clamp(wa_y, max_y))
 }
 
+/// 光标是否落在窗口内的宠物包围盒上(用于「悬停在宠物身上 → 暂停活动」)。
+pub fn cursor_over_pet(cursor: (f64, f64), win_pos: (i32, i32)) -> bool {
+    let left = (win_pos.0 + PET_BOX_OFFSET_X) as f64;
+    let top = (win_pos.1 + PET_BOX_OFFSET_Y) as f64;
+    let right = left + PET_BOX_W as f64;
+    let bottom = top + PET_BOX_H as f64;
+    cursor.0 >= left && cursor.0 <= right && cursor.1 >= top && cursor.1 <= bottom
+}
+
 /// 工作区内的确定性随机落点(LCG 单步,便于单测)。
 pub fn pick_random_target(wa_x: i32, wa_y: i32, wa_w: i32, wa_h: i32, seed: u64) -> (i32, i32) {
     let s = seed
@@ -179,8 +195,10 @@ pub fn spawn(app: AppHandle) {
                 shake_cooldown -= 1;
             }
 
-            // 采样全局光标(用于抖动检测 / 站手跟随)
+            // 采样全局光标(用于抖动检测 / 站手跟随 / 悬停暂停)
+            let mut cursor: Option<(f64, f64)> = None;
             if let Ok(cur) = win.cursor_position() {
+                cursor = Some((cur.x, cur.y));
                 samples.push(Sample { x: cur.x, y: cur.y, t: now });
                 if samples.len() > MAX_SAMPLES {
                     samples.remove(0);
@@ -229,6 +247,14 @@ pub fn spawn(app: AppHandle) {
                 emit_gait(&app, &mut last_gait, "idle", "none");
             }
 
+            // 鼠标悬停在宠物身上时暂停自主动作(走/跳/飞 → 立即待机)
+            let hovered = cursor.map(|c| cursor_over_pet(c, last_pos)).unwrap_or(false);
+            if hovered && matches!(mode, Mode::Walk | Mode::Hop | Mode::Fly) {
+                mode = Mode::Idle;
+                idle_ticks = 0;
+                emit_gait(&app, &mut last_gait, "idle", "none");
+            }
+
             match mode {
                 Mode::Fly => {
                     if fly_left > 0 {
@@ -263,23 +289,35 @@ pub fn spawn(app: AppHandle) {
                     }
                 }
                 _ => {
-                    idle_ticks += 1;
-                    let threshold = IDLE_MIN_TICKS + (next_rand(&mut seed) % IDLE_JITTER_TICKS) as u32;
-                    if idle_ticks >= threshold {
+                    if hovered {
+                        // 悬停期间保持静止:不累积空闲计时,不触发新的走/跳
                         idle_ticks = 0;
-                        let hop = next_rand(&mut seed) % 2 == 0;
-                        let dir: i32 = if next_rand(&mut seed) % 2 == 0 { 1 } else { -1 };
-                        let dist = 60 + (next_rand(&mut seed) % 120) as i32;
-                        let (tx, ty) =
-                            clamp_window_pos(last_pos.0 + dir * dist, last_pos.1, wa_x, wa_y, wa_w, wa_h);
-                        target = (tx, ty);
-                        mode = if hop { Mode::Hop } else { Mode::Walk };
-                        emit_gait(
-                            &app,
-                            &mut last_gait,
-                            if hop { "hop" } else { "walk" },
-                            if dir > 0 { "right" } else { "left" },
-                        );
+                    } else {
+                        idle_ticks += 1;
+                        let threshold =
+                            IDLE_MIN_TICKS + (next_rand(&mut seed) % IDLE_JITTER_TICKS) as u32;
+                        if idle_ticks >= threshold {
+                            idle_ticks = 0;
+                            let hop = next_rand(&mut seed) % 2 == 0;
+                            let dir: i32 = if next_rand(&mut seed) % 2 == 0 { 1 } else { -1 };
+                            let dist = 60 + (next_rand(&mut seed) % 120) as i32;
+                            let (tx, ty) = clamp_window_pos(
+                                last_pos.0 + dir * dist,
+                                last_pos.1,
+                                wa_x,
+                                wa_y,
+                                wa_w,
+                                wa_h,
+                            );
+                            target = (tx, ty);
+                            mode = if hop { Mode::Hop } else { Mode::Walk };
+                            emit_gait(
+                                &app,
+                                &mut last_gait,
+                                if hop { "hop" } else { "walk" },
+                                if dir > 0 { "right" } else { "left" },
+                            );
+                        }
                     }
                 }
             }
@@ -360,4 +398,17 @@ mod tests {
             assert!((0..=880).contains(&y), "y={y}");
         }
     }
+
+    #[test]
+    fn cursor_over_pet_detects_hit_and_miss() {
+        // 窗口左上角 (100,100):宠物盒 = x∈[140,260], y∈[160,300]
+        assert!(cursor_over_pet((200.0, 250.0), (100, 100)));
+        assert!(!cursor_over_pet((10.0, 250.0), (100, 100)));
+        assert!(!cursor_over_pet((200.0, 10.0), (100, 100)));
+        // 边界包含
+        assert!(cursor_over_pet((140.0, 160.0), (100, 100)));
+        assert!(cursor_over_pet((260.0, 300.0), (100, 100)));
+        // 窗口移动后包围盒随之平移
+        assert!(cursor_over_pet((500.0, 600.0), (400, 450)));
+    }
 }

+ 51 - 10
cfc-desktop-pet/src/animations.css

@@ -23,16 +23,57 @@
 @keyframes worried_walk { 0%, 100% { transform: translateX(0) } 25% { transform: translateX(6px) } 75% { transform: translateX(-6px) } }
 
 /* ===== 精细化动作:姿态层(#pet-body)=====
-   由 locomotion.rs 的 pet://gait 事件驱动;与 #pet 上的情绪动画(.anim-*)分层叠加 */
-@keyframes walk_bob { 0%, 100% { transform: translateY(0) rotate(0deg) } 25% { transform: translateY(-2px) rotate(-3deg) } 75% { transform: translateY(0) rotate(3deg) } }
-@keyframes hop_squash { 0% { transform: translateY(0) scale(1, 1) } 30% { transform: translateY(-3px) scale(1.1, 0.9) } 55% { transform: translateY(-12px) scale(0.92, 1.1) } 80% { transform: translateY(0) scale(1, 1) } }
-@keyframes fly_flutter { 0%, 100% { transform: translateY(0) rotate(-6deg) scale(1, 1) } 25% { transform: translateY(-6px) rotate(4deg) scale(1.08, 0.92) } 50% { transform: translateY(-2px) rotate(-4deg) scale(0.95, 1.05) } 75% { transform: translateY(-8px) rotate(6deg) scale(1.06, 0.94) } }
-@keyframes perch_lean { 0%, 100% { transform: rotate(-2deg) } 50% { transform: rotate(2deg) } }
-
-.pose-walk { animation: walk_bob 0.9s ease-in-out infinite; }
-.pose-hop { animation: hop_squash 0.7s ease-in-out infinite; }
-.pose-fly { animation: fly_flutter 0.45s ease-in-out infinite; }
-.pose-perch { animation: perch_lean 1.6s ease-in-out infinite; }
+   由 locomotion.rs 的 pet://gait 事件驱动;与 #pet 上的情绪动画(.anim-*)分层叠加。
+   朝向:--face 由 .pose-face-left 切换(1=右,-1=左),关键帧统一 scaleX(var(--face,1))。 */
+
+/* 走:上下颠簸 + 左右重心摆 + 前后微倾,模拟迈步 */
+@keyframes walk_bob {
+  0%   { transform: scaleX(var(--face,1)) translateY(0)    rotate(0deg) }
+  25%  { transform: scaleX(var(--face,1)) translateY(-3px) rotate(-3.5deg) }
+  50%  { transform: scaleX(var(--face,1)) translateY(-1px) rotate(0deg) }
+  75%  { transform: scaleX(var(--face,1)) translateY(-3px) rotate(3.5deg) }
+  100% { transform: scaleX(var(--face,1)) translateY(0)    rotate(0deg) }
+}
+
+/* 跳着走:下蹲预备 → 起跳拉伸 → 滞空 → 落地挤压缓冲 */
+@keyframes hop_squash {
+  0%   { transform: scaleX(var(--face,1)) translateY(0)     scale(1, 1) }
+  18%  { transform: scaleX(var(--face,1)) translateY(2px)   scale(1.09, 0.9) }
+  45%  { transform: scaleX(var(--face,1)) translateY(-15px) scale(0.9, 1.15) }
+  62%  { transform: scaleX(var(--face,1)) translateY(-11px) scale(0.95, 1.05) }
+  82%  { transform: scaleX(var(--face,1)) translateY(0)     scale(1.07, 0.91) }
+  100% { transform: scaleX(var(--face,1)) translateY(0)     scale(1, 1) }
+}
+
+/* 飞:高频振翅(垂直挤压振荡)+ 抬升 + 侧倾 */
+@keyframes fly_flutter {
+  0%   { transform: scaleX(var(--face,1)) translateY(-2px)  rotate(-8deg) scale(1.02, 0.98) }
+  25%  { transform: scaleX(var(--face,1)) translateY(-11px) rotate(5deg)  scale(0.93, 1.09) }
+  50%  { transform: scaleX(var(--face,1)) translateY(-4px)  rotate(-6deg) scale(1.07, 0.93) }
+  75%  { transform: scaleX(var(--face,1)) translateY(-13px) rotate(7deg)  scale(0.91, 1.11) }
+  100% { transform: scaleX(var(--face,1)) translateY(-2px)  rotate(-8deg) scale(1.02, 0.98) }
+}
+
+/* 站手:随手指轻晃 + 微起伏 */
+@keyframes perch_lean {
+  0%   { transform: scaleX(var(--face,1)) rotate(-2.5deg) translateY(0) }
+  50%  { transform: scaleX(var(--face,1)) rotate(2.5deg)  translateY(-2px) }
+  100% { transform: scaleX(var(--face,1)) rotate(-2.5deg) translateY(0) }
+}
+
+/* 待机:极轻微呼吸起伏(无位移,避免与情绪动画冲突) */
+@keyframes idle_breathe {
+  0%   { transform: scaleX(var(--face,1)) scale(1, 1) }
+  50%  { transform: scaleX(var(--face,1)) scale(1.02, 0.985) }
+  100% { transform: scaleX(var(--face,1)) scale(1, 1) }
+}
+
+.pose-walk { animation: walk_bob 0.75s ease-in-out infinite; }
+.pose-hop { animation: hop_squash 0.62s ease-in-out infinite; }
+.pose-fly { animation: fly_flutter 0.3s ease-in-out infinite; }
+.pose-perch { animation: perch_lean 1.8s ease-in-out infinite; }
+.pose-idle { animation: idle_breathe 3.2s ease-in-out infinite; }
+.pose-face-left { --face: -1; }
 
 .anim-wings_idle { animation: wings_idle 2s ease-in-out infinite; }
 .anim-wings_spread { animation: wings_spread 1.2s ease-in-out 1; }

+ 1 - 0
cfc-desktop-pet/src/index.html

@@ -23,6 +23,7 @@
   #pet-body {
     position: relative; width: 100%; height: 100%;
     transform-origin: 50% 100%;
+    cursor: pointer; --face: 1;
   }
   #pet img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
   /* 胸部热区:悬停 5s → 站手跟随(纯 hover,无需按键) */

+ 16 - 9
cfc-desktop-pet/src/pet-pose.js

@@ -6,24 +6,31 @@
   var petBody = document.getElementById('pet-body');
   if (!petBody) return;
 
-  var POSES = { walk: 'pose-walk', hop: 'pose-hop', fly: 'pose-fly', perch: 'pose-perch' };
+  var POSES = { idle: 'pose-idle', walk: 'pose-walk', hop: 'pose-hop', fly: 'pose-fly', perch: 'pose-perch' };
   var currentPose = null;
+  var lastDir = 'right';
 
-  function apply(kind) {
-    var cls = (kind && kind !== 'idle' && POSES[kind]) ? POSES[kind] : null;
-    if (currentPose === cls) return;
-    if (currentPose) petBody.classList.remove(currentPose);
-    currentPose = cls;
-    if (cls) {
-      void petBody.offsetWidth; // 强制回流,确保可重播
+  function apply(kind, direction) {
+    if (direction === 'left' || direction === 'right') lastDir = direction;
+    var cls = POSES[kind] || 'pose-idle'; // 未知 kind / idle → 呼吸待机
+    if (currentPose !== cls) {
+      if (currentPose) petBody.classList.remove(currentPose);
+      currentPose = cls;
+      void petBody.offsetWidth; // 强制回流,确保动画可重播
       petBody.classList.add(cls);
     }
+    // 朝向:left → 水平镜像(fly/perch 的 direction 为 none,沿用上次朝向)
+    petBody.classList.toggle('pose-face-left', lastDir === 'left');
   }
 
+  // 初始即进入呼吸待机(Rust 侧初始 gait 不会 emit,故此处兜底)
+  apply('idle');
+
   try {
     var listen = window.__TAURI__.event.listen;
     listen('pet://gait', function (e) {
-      apply(e.payload && e.payload.kind);
+      var p = (e && e.payload) || {};
+      apply(p.kind, p.direction);
     });
     listen('pet://perch', function (e) {
       window.__petPerched = !!(e.payload && e.payload.active);

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

@@ -23,8 +23,6 @@
 <body>
   <label for="base-url">后端地址</label>
   <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>
 
   <hr />
@@ -49,7 +47,6 @@
       'use strict';
       var invoke = window.__TAURI__.core.invoke;
       var baseUrlEl = document.getElementById('base-url');
-      var tokenEl = document.getElementById('token');
       var statusEl = document.getElementById('status');
       var fileEl = document.getElementById('mascot-file');
       var previewEl = document.getElementById('mascot-preview');
@@ -64,13 +61,12 @@
           previewEl.src = s.customMascotUrl;
           previewEl.style.display = 'block';
         }
-        setStatus(s.hasToken ? '已保存令牌' : '未设置令牌');
+        setStatus('');
       }).catch(function () {});
 
       document.getElementById('save').addEventListener('click', function () {
         var baseUrl = baseUrlEl.value.trim();
-        var token = tokenEl.value.trim();
-        invoke('save_settings', { baseUrl: baseUrl, token: token ? token : null })
+        invoke('save_settings', { baseUrl: baseUrl })
           .then(function () {
             setStatus('已保存,轮询已重启');
             setTimeout(function () { setStatus(''); }, 2500);
@@ -160,7 +156,6 @@
           var p = (ev && ev.payload) || {};
           if (p.status === 'success') {
             setStatus('登录成功' + (p.nickname ? ':' + p.nickname : ''));
-            tokenEl.value = '';
             document.getElementById('wechat-qr').style.display = 'none';
           } else if (p.status === 'pending') {
             setStatus('等待扫码…');