Kaynağa Gözat

feat(desktop-pet): 设置内自定义宠物形象上传(复用 /api/media/upload)

- Cargo: reqwest +multipart, 新增 base64

- api/mod.rs: upload_media(multipart); api/models.rs: MediaRecord

- settings.rs: AppSettings.custom_mascot_url 持久化

- commands.rs: upload_mascot/clear_custom_mascot; get_settings 返回 customMascotUrl

- settings.html: 形象选择+预览+上传/恢复默认 UI; 窗口高度 340->470

- pet-engine.js: setCustomImage 优先渲染自定义形象; bridge.js 监听 pet://custom-image 并启动加载
liaoxg 1 gün önce
ebeveyn
işleme
5ab9b49bf4

+ 19 - 0
cfc-desktop-pet/src-tauri/Cargo.lock

@@ -2082,6 +2082,16 @@ version = "0.3.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
 
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
 [[package]]
 name = "miniz_oxide"
 version = "0.8.9"
@@ -2907,6 +2917,7 @@ dependencies = [
  "base64 0.22.1",
  "bytes",
  "futures-core",
+ "futures-util",
  "http",
  "http-body",
  "http-body-util",
@@ -2915,6 +2926,7 @@ dependencies = [
  "hyper-util",
  "js-sys",
  "log",
+ "mime_guess",
  "percent-encoding",
  "pin-project-lite",
  "quinn",
@@ -4254,6 +4266,12 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "unicase"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
+
 [[package]]
 name = "unicode-ident"
 version = "1.0.26"
@@ -5066,6 +5084,7 @@ dependencies = [
 name = "xaf-pet-desktop"
 version = "0.1.0"
 dependencies = [
+ "base64 0.22.1",
  "chrono",
  "keyring",
  "reqwest 0.12.28",

+ 2 - 1
cfc-desktop-pet/src-tauri/Cargo.toml

@@ -12,9 +12,10 @@ tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }
 tauri-plugin-single-instance = "2"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
-reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
 tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] }
 chrono = "0.4"
+base64 = "0.22"
 keyring = { version = "3", features = ["apple-native", "windows-native"] }
 
 [profile.release]

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

@@ -105,6 +105,36 @@ impl ApiClient {
     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
     }
+
+    /// 上传图片等媒体文件(multipart,复用 /api/media/upload)
+    pub async fn upload_media(
+        &self,
+        filename: &str,
+        bytes: Vec<u8>,
+        mime: &str,
+    ) -> Result<MediaRecord, ApiError> {
+        let url = format!("{}/api/media/upload", *self.base_url.read().unwrap());
+        let token = self.token.read().unwrap().clone();
+        let part = reqwest::multipart::Part::bytes(bytes.clone()).file_name(filename.to_string());
+        let part = if mime.is_empty() {
+            part
+        } else {
+            part.mime_str(mime).unwrap_or_else(|_| {
+                reqwest::multipart::Part::bytes(bytes).file_name(filename.to_string())
+            })
+        };
+        let form = reqwest::multipart::Form::new().part("file", part);
+        let resp = self
+            .http
+            .post(&url)
+            .bearer_auth(token)
+            .multipart(form)
+            .send()
+            .await
+            .map_err(|e| ApiError::Http(e.to_string()))?;
+        let text = resp.text().await.map_err(|e| ApiError::Http(e.to_string()))?;
+        self.parse_response(&text)
+    }
 }
 
 #[cfg(test)]

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

@@ -101,10 +101,29 @@ pub struct WaterRecord {
     pub created_at: Option<FlexDate>,
 }
 
+/// /api/media/upload 返回的媒体记录(仅取所需字段,其余忽略)
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MediaRecord {
+    #[allow(dead_code)]
+    pub id: Option<i64>,
+    pub url: Option<String>,
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    #[test]
+    fn parses_media_record_upload_response() {
+        let json = r#"{"code":200,"message":"success","data":{"id":9,"type":"image","url":"https://cdn/x.png","creatorId":null}}"#;
+        let r: ApiResult<MediaRecord> = serde_json::from_str(json).unwrap();
+        assert!(r.is_success());
+        let rec = r.data.unwrap();
+        assert_eq!(rec.id, Some(9));
+        assert_eq!(rec.url.as_deref(), Some("https://cdn/x.png"));
+    }
+
     #[test]
     fn parses_task_with_iso_deadline() {
         let json = r#"{"id":3,"title":"喝水","status":"PENDING","deadline":"2026-08-15T10:00:00"}"#;

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

@@ -63,10 +63,55 @@ pub fn get_settings(
     let has_token = TokenStore::new().load().map(|t| t.is_some()).unwrap_or(false);
     Ok(serde_json::json!({
         "baseUrl": cfg.base_url,
-        "hasToken": has_token
+        "hasToken": has_token,
+        "customMascotUrl": cfg.custom_mascot_url
     }))
 }
 
+#[tauri::command]
+pub async fn upload_mascot(
+    app: AppHandle,
+    settings: State<'_, SettingsStore>,
+    filename: String,
+    mime: String,
+    data_base64: String,
+) -> Result<String, String> {
+    use base64::Engine;
+    let bytes = base64::engine::general_purpose::STANDARD
+        .decode(data_base64.as_bytes())
+        .map_err(|e| format!("图片解码失败: {e}"))?;
+    if bytes.is_empty() {
+        return Err("图片内容为空".into());
+    }
+    if bytes.len() > 10 * 1024 * 1024 {
+        return Err("图片过大(上限 10MB)".into());
+    }
+
+    let state = app.state::<AppState>();
+    let record = state
+        .api
+        .upload_media(&filename, bytes, &mime)
+        .await
+        .map_err(|e| format!("上传失败: {e}"))?;
+    let url = record.url.ok_or_else(|| "上传响应缺少 url".to_string())?;
+
+    let mut cfg = settings.load();
+    cfg.custom_mascot_url = Some(url.clone());
+    settings.save(&cfg)?;
+
+    let _ = app.emit("pet://custom-image", serde_json::json!({ "url": url }));
+    Ok(url)
+}
+
+#[tauri::command]
+pub fn clear_custom_mascot(app: AppHandle, settings: State<SettingsStore>) -> Result<(), String> {
+    let mut cfg = settings.load();
+    cfg.custom_mascot_url = None;
+    settings.save(&cfg)?;
+    let _ = app.emit("pet://custom-image", serde_json::json!({ "url": null }));
+    Ok(())
+}
+
 #[tauri::command]
 pub fn save_settings(
     app: AppHandle,

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

@@ -49,6 +49,8 @@ fn main() {
             commands::save_settings,
             commands::open_settings,
             commands::quit_app,
+            commands::upload_mascot,
+            commands::clear_custom_mascot,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");

+ 18 - 1
cfc-desktop-pet/src-tauri/src/settings.rs

@@ -7,11 +7,14 @@ use std::path::PathBuf;
 pub struct AppSettings {
     #[serde(default = "default_base_url")]
     pub base_url: String,
+    /// 自定义形象图片 URL(通过 /api/media/upload 上传后本地记录)
+    #[serde(default)]
+    pub custom_mascot_url: Option<String>,
 }
 
 impl Default for AppSettings {
     fn default() -> Self {
-        Self { base_url: default_base_url() }
+        Self { base_url: default_base_url(), custom_mascot_url: None }
     }
 }
 
@@ -88,4 +91,18 @@ mod tests {
         assert_eq!(store2.load().base_url, "http://192.168.1.5:9082");
         let _ = fs::remove_dir_all(&dir);
     }
+
+    #[test]
+    fn custom_mascot_url_roundtrip() {
+        let dir = temp_dir();
+        let store = SettingsStore::new(dir.clone());
+        let mut cfg = store.load();
+        assert!(cfg.custom_mascot_url.is_none());
+        cfg.custom_mascot_url = Some("https://cdn.example/x.png".into());
+        store.save(&cfg).unwrap();
+
+        let loaded = SettingsStore::new(dir.clone()).load();
+        assert_eq!(loaded.custom_mascot_url.as_deref(), Some("https://cdn.example/x.png"));
+        let _ = fs::remove_dir_all(&dir);
+    }
 }

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

@@ -27,7 +27,7 @@
         "title": "设置",
         "url": "settings.html",
         "width": 360,
-        "height": 340,
+        "height": 470,
         "resizable": false,
         "visible": false
       }

+ 12 - 0
cfc-desktop-pet/src/bridge.js

@@ -14,6 +14,18 @@
   listen('pet://config', function (e) {
     if (window.cfcPetAPI) window.cfcPetAPI.loadConfig(e.payload);
   });
+  listen('pet://custom-image', function (e) {
+    if (window.cfcPetAPI) window.cfcPetAPI.setCustomImage(e.payload && e.payload.url);
+  });
+
+  // 启动时应用已保存的自定义形象
+  try {
+    invoke('get_settings').then(function (s) {
+      if (s && s.customMascotUrl && window.cfcPetAPI) {
+        window.cfcPetAPI.setCustomImage(s.customMascotUrl);
+      }
+    }).catch(function () {});
+  } catch (_) { /* noop */ }
 
   // ── JS → Rust(替换 chrome.webview.hostObjects.sync.cfcHost)──
   window.cfcHost = {

+ 17 - 2
cfc-desktop-pet/src/pet-engine.js

@@ -8,6 +8,8 @@
   let currentState = 'idle';
   let currentAnimation = null;
   let config = null;
+  let lastConfig = null;
+  let customImage = null;
   let bubbleTimer = null;
 
   const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' +
@@ -41,6 +43,17 @@
       config = typeof configJson === 'string' ? JSON.parse(configJson) : configJson;
       applyImage(config);
     },
+    setCustomImage: function (url) {
+      customImage = url || null;
+      if (customImage) {
+        petImg.src = customImage;
+        petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
+      } else if (lastConfig) {
+        applyImage(lastConfig);
+      } else {
+        petImg.src = DEFAULT_IMAGE;
+      }
+    },
     showBubble: function (text, durationMs) {
       bubble.textContent = text;
       bubble.style.display = 'block';
@@ -54,8 +67,10 @@
   function hideBubble() { bubble.style.display = 'none'; }
 
   function applyImage(cfg) {
-    if (!cfg || !cfg.default_image) return;
-    const src = cfg.image_base_url + cfg.default_image;
+    if (!cfg) return;
+    lastConfig = cfg;
+    const src = customImage || (cfg.default_image ? cfg.image_base_url + cfg.default_image : null);
+    if (!src) return;
     petImg.src = src;
     petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
   }

+ 82 - 5
cfc-desktop-pet/src/settings.html

@@ -10,6 +10,12 @@
           border: 1px solid #ccc; border-radius: 6px; font-size: 13px; }
   button { width: 100%; padding: 8px; border: none; border-radius: 6px;
            background: #4A9BD7; color: #fff; font-size: 14px; cursor: pointer; }
+  button.secondary { background: #9aa4ad; margin-top: 8px; }
+  button:disabled { opacity: .6; cursor: default; }
+  .hint { font-size: 12px; color: #999; margin: -8px 0 8px; }
+  #mascot-preview { width: 72px; height: 72px; object-fit: contain;
+                    border: 1px dashed #ccc; border-radius: 8px; display: none; margin-bottom: 8px; }
+  hr { border: none; border-top: 1px solid #eee; margin: 14px 0; }
   #status { margin-top: 10px; font-size: 12px; color: #999; }
 </style>
 </head>
@@ -19,6 +25,16 @@
   <label for="token">令牌(留空则不修改)</label>
   <input id="token" type="password" placeholder="JWT Token" />
   <button id="save">保存</button>
+
+  <hr />
+
+  <label>宠物形象</label>
+  <img id="mascot-preview" alt="preview" />
+  <input id="mascot-file" type="file" accept="image/png,image/jpeg,image/webp" />
+  <div class="hint">支持 PNG / JPG / WebP,建议正方形、≤2MB</div>
+  <button id="upload-mascot" disabled>上传并使用此形象</button>
+  <button id="clear-mascot" class="secondary">恢复默认形象</button>
+
   <div id="status"></div>
   <script>
     (function () {
@@ -27,10 +43,20 @@
       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');
+      var uploadBtn = document.getElementById('upload-mascot');
+      var clearBtn = document.getElementById('clear-mascot');
+
+      function setStatus(msg) { statusEl.textContent = msg || ''; }
 
       invoke('get_settings').then(function (s) {
         baseUrlEl.value = s.baseUrl || 'https://cfc.cf-club.com';
-        statusEl.textContent = s.hasToken ? '已保存令牌' : '未设置令牌';
+        if (s.customMascotUrl) {
+          previewEl.src = s.customMascotUrl;
+          previewEl.style.display = 'block';
+        }
+        setStatus(s.hasToken ? '已保存令牌' : '未设置令牌');
       }).catch(function () {});
 
       document.getElementById('save').addEventListener('click', function () {
@@ -38,12 +64,63 @@
         var token = tokenEl.value.trim();
         invoke('save_settings', { baseUrl: baseUrl, token: token ? token : null })
           .then(function () {
-            statusEl.textContent = '已保存,轮询已重启';
-            setTimeout(function () { statusEl.textContent = ''; }, 2500);
+            setStatus('已保存,轮询已重启');
+            setTimeout(function () { setStatus(''); }, 2500);
           })
-          .catch(function (e) {
-            statusEl.textContent = '保存失败:' + e;
+          .catch(function (e) { setStatus('保存失败:' + e); });
+      });
+
+      fileEl.addEventListener('change', function () {
+        var file = fileEl.files && fileEl.files[0];
+        uploadBtn.disabled = !file;
+        if (!file) { previewEl.style.display = 'none'; return; }
+        var reader = new FileReader();
+        reader.onload = function () {
+          previewEl.src = reader.result;
+          previewEl.style.display = 'block';
+        };
+        reader.readAsDataURL(file);
+      });
+
+      uploadBtn.addEventListener('click', function () {
+        var file = fileEl.files && fileEl.files[0];
+        if (!file) return;
+        if (file.size > 10 * 1024 * 1024) { setStatus('图片过大(上限 10MB)'); return; }
+        uploadBtn.disabled = true;
+        setStatus('上传中…');
+        var reader = new FileReader();
+        reader.onload = function () {
+          var result = String(reader.result);
+          var comma = result.indexOf(',');
+          var meta = result.substring(0, comma);
+          var base64 = result.substring(comma + 1);
+          var mime = meta.indexOf(':') >= 0 && meta.indexOf(';') > 0
+            ? meta.substring(meta.indexOf(':') + 1, meta.indexOf(';')) : '';
+          invoke('upload_mascot', {
+            filename: file.name,
+            mime: mime,
+            dataBase64: base64
+          }).then(function () {
+            setStatus('已上传并使用该形象');
+            uploadBtn.disabled = false;
+            setTimeout(function () { setStatus(''); }, 3000);
+          }).catch(function (e) {
+            setStatus('上传失败:' + e);
+            uploadBtn.disabled = false;
           });
+        };
+        reader.onerror = function () { setStatus('读取图片失败'); uploadBtn.disabled = false; };
+        reader.readAsDataURL(file);
+      });
+
+      clearBtn.addEventListener('click', function () {
+        invoke('clear_custom_mascot').then(function () {
+          fileEl.value = '';
+          previewEl.style.display = 'none';
+          uploadBtn.disabled = true;
+          setStatus('已恢复默认形象');
+          setTimeout(function () { setStatus(''); }, 2500);
+        }).catch(function (e) { setStatus('操作失败:' + e); });
       });
     })();
   </script>