Просмотр исходного кода

feat: 浠艾福桌面宠物 v1.0 完成(WPF + WebView2 架构)

新模块:cfc-desktop-pet/
- 架构:WPF (.NET 6) 主进程 + WebView2 渲染层,Bridge via cfcPetAPI
- 状态映射:14 种核心状态 + 用户自定义状态(custom_state_1~10)
- API 对接:POST /api/tasks/today、/api/energy/overview、/api/daily/checkin
- 形象配置:JSON Schema (PetConfig),支持浠宝/福宝预设 + 用户自定义 PNG
- 交互闭环:喝水提醒 → 点击确认 → API 打卡 → 庆祝动画
- 托盘常驻:最小化到托盘,右键菜单(设置/显示/退出),双击唤出
- Token 存储:Windows DPAPI 加密,%APPDATA%\XafPet\token.bin
- 动画库:20 个 CSS keyframes(idle/happy/busy/sad/celebrate 等)
- 测试:xUnit + Moq 单元测试 18 个(PetConfig/ApiClient/StateManager)

Windows 构建命令:
  dotnet build XafPet.Desktop.sln
  dotnet test tests/XafPet.Desktop.Tests
  dotnet publish src/XafPet.Desktop/XafPet.Desktop.csproj -c Release -r win-x64
Sisyphus 1 день назад
Родитель
Сommit
521f307c24
29 измененных файлов с 1468 добавлено и 0 удалено
  1. 42 0
      cfc-desktop-pet/README.md
  2. 24 0
      cfc-desktop-pet/XafPet.Desktop.sln
  3. 6 0
      cfc-desktop-pet/src/XafPet.Desktop/App.xaml
  4. 16 0
      cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs
  5. 46 0
      cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css
  6. 51 0
      cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js
  7. 105 0
      cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js
  8. 30 0
      cfc-desktop-pet/src/XafPet.Desktop/Assets/pet.html
  9. 27 0
      cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json
  10. 27 0
      cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json
  11. 16 0
      cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml
  12. 207 0
      cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs
  13. 78 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs
  14. 89 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs
  15. 73 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs
  16. 27 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs
  17. 62 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs
  18. 11 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml
  19. 77 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml.cs
  20. 20 0
      cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml
  21. 44 0
      cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml.cs
  22. 23 0
      cfc-desktop-pet/src/XafPet.Desktop/SingleInstanceHelper.cs
  23. 43 0
      cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs
  24. 28 0
      cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj
  25. 96 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs
  26. 68 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs
  27. 8 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/README.md
  28. 107 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs
  29. 17 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj

+ 42 - 0
cfc-desktop-pet/README.md

@@ -0,0 +1,42 @@
+# 浠艾福桌面宠物 (XafPet.Desktop)
+
+Windows 桌面宠物应用,实时反映 CFC 健康陪跑状态。
+
+## 功能
+
+- 显示今日任务进度 / 五维能量 / 健康打卡状态
+- 喝水提醒互动(点击宠物确认打卡)
+- 支持浠宝/福宝及自定义形象(PNG + pet-config.json)
+- 支持用户自定义状态(custom_state_1 ~ custom_state_10)
+- 托盘常驻,最小化到托盘,双击唤出
+
+## 使用
+
+1. 发布:`dotnet publish src/XafPet.Desktop/XafPet.Desktop.csproj -c Release -r win-x64`
+2. 运行 `XafPet.Desktop.exe`
+3. 首次启动 → 右键托盘图标 → 设置 → 填入后端地址 + JWT Token
+4. 宠物出现在桌面右下角,可拖拽移动
+
+## 自定义宠物
+
+1. 在 `%APPDATA%\XafPet\pets\<名称>\` 放置 `standing.png` + `pet-config.json`
+2. 重启应用,设置中选择形象
+
+## 开发
+
+```bash
+# 构建
+dotnet build XafPet.Desktop.sln
+
+# 测试(需 Windows 机器,.NET 6 SDK)
+dotnet test tests/XafPet.Desktop.Tests -v
+```
+
+## 环境要求
+
+- Windows 10 (1803+) / Windows 11
+- .NET 6 Runtime(发布时 Self-Contained,自带 WebView2 Runtime)
+- CFC 后端(localhost:9082)可选,不连接后端时宠物保持 idle 状态
+
+---
+> **注意:** 本项目仅在 Windows 上构建/运行。本代码仓库的其他模块(cfc-backend/cfc-web/cfc-frontend)不受影响。

+ 24 - 0
cfc-desktop-pet/XafPet.Desktop.sln

@@ -0,0 +1,24 @@
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XafPet.Desktop", "src\XafPet.Desktop\XafPet.Desktop.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C3D4E5F6-A7B8-9012-CDEF-123456789012}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XafPet.Desktop.Tests", "tests\XafPet.Desktop.Tests\XafPet.Desktop.Tests.csproj", "{D4E5F6A7-B8C9-0123-DEFG-234567890123}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{D4E5F6A7-B8C9-0123-DEFG-234567890123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{D4E5F6A7-B8C9-0123-DEFG-234567890123}.Release|Any CPU.ActiveCfg = Release|Any CPU
+	EndGlobalSection
+EndGlobal

+ 6 - 0
cfc-desktop-pet/src/XafPet.Desktop/App.xaml

@@ -0,0 +1,6 @@
+<Application x:Class="XafPet.Desktop.App"
+             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             StartupUri="MainWindow.xaml">
+    <Application.Resources></Application.Resources>
+</Application>

+ 16 - 0
cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs

@@ -0,0 +1,16 @@
+using System.Windows;
+
+namespace XafPet.Desktop;
+
+public partial class App : Application
+{
+    protected override void OnStartup(StartupEventArgs e)
+    {
+        base.OnStartup(e);
+        if (!SingleInstanceHelper.TryEnterFirstInstance())
+        {
+            Current.Shutdown();
+            return;
+        }
+    }
+}

+ 46 - 0
cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css

@@ -0,0 +1,46 @@
+/* ===== 浠艾福桌面宠物动画定义 ===== */
+@keyframes wings_idle { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-3px) } }
+@keyframes wings_spread { 0% { transform: scale(1) } 30% { transform: scale(1.15, 0.95) } 70% { transform: scale(0.95, 1.1) } 100% { transform: scale(1) } }
+@keyframes head_tilt { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-8deg) } 75% { transform: rotate(6deg) } }
+@keyframes hand_gesture { 0%, 100% { transform: translateY(0) } 20% { transform: translateY(-8px) } 40% { transform: translateY(0) } 60% { transform: translateY(-5px) } 80% { transform: translateY(0) } }
+@keyframes breath_loop { 0%, 100% { transform: scale(1, 1) } 50% { transform: scale(1.04, 0.96) } }
+@keyframes celebrate { 0%, 100% { transform: translateY(0) rotate(0) } 25% { transform: translateY(-16px) rotate(-4deg) } 50% { transform: translateY(0) rotate(0) } 75% { transform: translateY(-16px) rotate(4deg) } }
+@keyframes sad_animation { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(2px) } }
+@keyframes tail_wrap { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(3deg) } }
+@keyframes belly_show { 0%, 100% { transform: rotate(0deg) scale(1) } 50% { transform: rotate(-4deg) scale(1.05) } }
+@keyframes paw_press { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(3px) } }
+@keyframes tail_flick { 0%, 100% { transform: rotate(0deg) } 30% { transform: rotate(10deg) } 60% { transform: rotate(-10deg) } }
+@keyframes blink_eye { 0%, 90%, 100% { transform: scaleY(1) } 95% { transform: scaleY(0.1) } }
+@keyframes look_around { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-12deg) } 75% { transform: rotate(12deg) } }
+@keyframes wave { 0%, 100% { transform: rotate(0deg) } 25% { transform: rotate(-15deg) } 50% { transform: rotate(10deg) } 75% { transform: rotate(-15deg) } }
+@keyframes stretch { 0%, 100% { transform: scaleY(1) } 50% { transform: scaleY(1.15) } }
+@keyframes clap { 0%, 100% { transform: scale(1) } 40% { transform: scale(1.1, 0.9) } 70% { transform: scale(0.95, 1.05) } }
+@keyframes bow { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(10deg) } }
+@keyframes whistle { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-4px) } }
+@keyframes ear_scrape { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(6deg) } }
+@keyframes pet_touch { 0% { transform: scale(1) } 50% { transform: scale(1.08) } 100% { transform: scale(1) } }
+@keyframes puzzle_active { 0%, 100% { transform: rotate(0deg) } 50% { transform: rotate(180deg) } }
+@keyframes worried_walk { 0%, 100% { transform: translateX(0) } 25% { transform: translateX(6px) } 75% { transform: translateX(-6px) } }
+
+.anim-wings_idle { animation: wings_idle 2s ease-in-out infinite; }
+.anim-wings_spread { animation: wings_spread 1.2s ease-in-out 1; }
+.anim-head_tilt { animation: head_tilt 1.6s ease-in-out infinite; }
+.anim-hand_gesture { animation: hand_gesture 1.8s ease-in-out infinite; }
+.anim-breath_loop { animation: breath_loop 2.4s ease-in-out infinite; }
+.anim-celebrate { animation: celebrate 0.8s ease-in-out 3; }
+.anim-sad_animation { animation: sad_animation 3s ease-in-out infinite; }
+.anim-tail_wrap { animation: tail_wrap 2s ease-in-out infinite; }
+.anim-belly_show { animation: belly_show 1.4s ease-in-out 2; }
+.anim-paw_press { animation: paw_press 1s ease-in-out 2; }
+.anim-tail_flick { animation: tail_flick 1.5s ease-in-out 2; }
+.anim-blink_eye { animation: blink_eye 3.5s ease-in-out infinite; }
+.anim-look_around { animation: look_around 2s ease-in-out 1; }
+.anim-wave { animation: wave 1.2s ease-in-out 2; }
+.anim-stretch { animation: stretch 1.8s ease-in-out 1; }
+.anim-clap { animation: clap 0.6s ease-in-out 3; }
+.anim-bow { animation: bow 1.2s ease-in-out 2; }
+.anim-whistle { animation: whistle 0.9s ease-in-out 3; }
+.anim-ear_scrape { animation: ear_scrape 1s ease-in-out 2; }
+.anim-pet_touch { animation: pet_touch 0.6s ease-in-out 1; }
+.anim-puzzle_active { animation: puzzle_active 1s ease-in-out infinite; }
+.anim-worried_walk { animation: worried_walk 1.2s ease-in-out infinite; }

+ 51 - 0
cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js

@@ -0,0 +1,51 @@
+(function () {
+  'use strict';
+
+  const petEl = document.getElementById('pet');
+
+  let dragStartX = 0, dragStartY = 0;
+  let pressTimer = null;
+  let isDragging = false;
+
+  petEl.addEventListener('mousedown', function (e) {
+    dragStartX = e.clientX;
+    dragStartY = e.clientY;
+    isDragging = false;
+    pressTimer = setTimeout(() => {
+      notifyInteract('pet', {});
+    }, 1500);
+  });
+
+  petEl.addEventListener('mousemove', function (e) {
+    const dx = e.clientX - dragStartX;
+    const dy = e.clientY - dragStartY;
+    if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
+      isDragging = true;
+      clearTimeout(pressTimer);
+      notifyDragStart();
+    }
+  });
+
+  petEl.addEventListener('mouseup', function (e) {
+    clearTimeout(pressTimer);
+    if (!isDragging) {
+      notifyInteract('click', {});
+    } else {
+      notifyDragEnd();
+    }
+  });
+
+  function notifyInteract(action, data) {
+    try {
+      chrome.webview.hostObjects.sync.cfcHost.onInteract(action, JSON.stringify(data));
+    } catch (err) { console.log('[interaction]', action, data); }
+  }
+
+  function notifyDragStart() {
+    try { chrome.webview.hostObjects.sync.cfcHost.onDragStart(); } catch (e) { /* noop */ }
+  }
+
+  function notifyDragEnd() {
+    try { chrome.webview.hostObjects.sync.cfcHost.onDragEnd(); } catch (e) { /* noop */ }
+  }
+})();

+ 105 - 0
cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js

@@ -0,0 +1,105 @@
+(function () {
+  'use strict';
+
+  const petImg = document.getElementById('pet-img');
+  const petEl = document.getElementById('pet');
+  const bubble = document.getElementById('bubble');
+
+  let currentState = 'idle';
+  let currentAnimation = null;
+  let config = null;
+  let bubbleTimer = null;
+
+  const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' +
+    encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="140">' +
+      '<text y="100" font-size="90" text-anchor="middle">🦜</text></svg>');
+
+  const BUBBLE_TEXTS = {
+    happy: '完成了!继续保持~',
+    busy: '有任务在等你呢~',
+    worried: '任务要过期啦,快去看看!',
+    excited: '打卡成功,太棒了!',
+    remind: '该喝水啦~',
+    tired: '有点累了呢…',
+    sad: '要照顾好自己哦…',
+    waitingForInteraction: '来和我互动一下吧!',
+    miniGame: '我们一起玩吧!',
+    sleeping: 'zZ…',
+    custom: '自定义状态'
+  };
+
+  window.cfcPetAPI = {
+    notifyState: function (stateJson) {
+      const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
+      setState(payload.state, payload.reason || null, payload.data || null);
+    },
+    setCustomState: function (stateName, dataJson) {
+      const data = typeof dataJson === 'string' ? JSON.parse(dataJson) : dataJson;
+      setState(stateName, 'custom', data);
+    },
+    loadConfig: function (configJson) {
+      config = typeof configJson === 'string' ? JSON.parse(configJson) : configJson;
+      applyImage(config);
+    },
+    showBubble: function (text, durationMs) {
+      bubble.textContent = text;
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      if (durationMs) bubbleTimer = setTimeout(hideBubble, durationMs);
+    },
+    hideBubble: hideBubble,
+    getState: function () { return currentState; }
+  };
+
+  function hideBubble() { bubble.style.display = 'none'; }
+
+  function applyImage(cfg) {
+    if (!cfg || !cfg.default_image) return;
+    const src = cfg.image_base_url + cfg.default_image;
+    petImg.src = src;
+    petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
+  }
+
+  function setState(stateName, reason, data) {
+    currentState = stateName;
+    if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; }
+
+    let animClass = null;
+    if (config && config.actions && config.actions[stateName]) {
+      animClass = 'anim-' + config.actions[stateName];
+    } else {
+      animClass = 'anim-' + stateName;
+    }
+
+    const styleEl = document.getElementById('pet-animations');
+    const sheet = styleEl ? styleEl.sheet : null;
+    const hasClass = sheet && Array.from(sheet.cssRules).some(r => r.selectorText === '.' + animClass);
+    if (hasClass) { petEl.classList.add(animClass); currentAnimation = animClass; }
+
+    if (reason === 'drink_water') {
+      bubble.textContent = '该喝水啦~';
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 8000);
+    } else if (reason === 'welcome') {
+      bubble.textContent = '我来了!';
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 3000);
+    } else if (BUBBLE_TEXTS[stateName]) {
+      bubble.textContent = BUBBLE_TEXTS[stateName];
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 5000);
+    }
+  }
+
+  petImg.src = DEFAULT_IMAGE;
+
+  setInterval(function () {
+    if (document.getElementById('pet-img')) {
+      petImg.classList.add('anim-blink_eye');
+      setTimeout(() => petImg.classList.remove('anim-blink_eye'), 200);
+    }
+  }, 3500);
+})();

+ 30 - 0
cfc-desktop-pet/src/XafPet.Desktop/Assets/pet.html

@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8" />
+<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; }
+  #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; }
+</style>
+</head>
+<body>
+<div id="pet-stage">
+  <div id="pet">
+    <img id="pet-img" src="" alt="pet" draggable="false" />
+    <div id="bubble"></div>
+  </div>
+</div>
+<script src="pet-engine.js"></script>
+<script src="interactions.js"></script>
+</body>
+</html>

+ 27 - 0
cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json

@@ -0,0 +1,27 @@
+{
+  "version": "1.0",
+  "type": "animal",
+  "species": "british_shorthair",
+  "gender": "male",
+  "image_base_url": "./DefaultPets/fubao/",
+  "default_image": "standing.png",
+  "actions": {
+    "idle": "tail_wrap",
+    "happy": "belly_show",
+    "busy": "paw_press",
+    "waitingForInteraction": "tail_flick",
+    "sleeping": "breath_loop",
+    "celebrate": "celebrate",
+    "sad": "sad_animation"
+  },
+  "sounds": {
+    "click": "tap.mp3",
+    "complete": "bell.mp3",
+    "remind": "water_please.mp3"
+  },
+  "parameters": {
+    "move_distance": 12,
+    "animation_speed": 1.0,
+    "interaction_timeout": 300
+  }
+}

+ 27 - 0
cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json

@@ -0,0 +1,27 @@
+{
+  "version": "1.0",
+  "type": "animal",
+  "species": "cockatiel",
+  "gender": "female",
+  "image_base_url": "./DefaultPets/xibao/",
+  "default_image": "standing.png",
+  "actions": {
+    "idle": "wings_idle",
+    "happy": "wings_spread",
+    "busy": "head_tilt",
+    "waitingForInteraction": "hand_gesture",
+    "sleeping": "breath_loop",
+    "celebrate": "celebrate",
+    "sad": "sad_animation"
+  },
+  "sounds": {
+    "click": "pip.mp3",
+    "complete": "cheer.mp3",
+    "remind": "water_please.mp3"
+  },
+  "parameters": {
+    "move_distance": 15,
+    "animation_speed": 1.0,
+    "interaction_timeout": 300
+  }
+}

+ 16 - 0
cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml

@@ -0,0 +1,16 @@
+<Window x:Class="XafPet.Desktop.MainWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        xmlns:pet="clr-namespace:XafPet.Desktop.PetEngine"
+        Title="浠艾福桌面宠物"
+        Width="200" Height="200"
+        WindowStyle="None"
+        AllowsTransparency="True"
+        Background="Transparent"
+        Topmost="True"
+        ShowInTaskbar="False"
+        ResizeMode="NoResize">
+    <Grid x:Name="RootGrid" Background="Transparent">
+        <pet:WebViewHost x:Name="PetHost" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
+    </Grid>
+</Window>

+ 207 - 0
cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs

@@ -0,0 +1,207 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using System.Timers;
+using System.Windows;
+using System.Windows.Forms;
+using XafPet.Desktop.PetEngine;
+
+namespace XafPet.Desktop;
+
+public partial class MainWindow : Window
+{
+    private NotifyIcon? _trayIcon;
+    private ApiClient? _api;
+    private Timer? _taskTimer;
+    private Timer? _energyTimer;
+    private Timer? _waterTimer;
+    private string _baseUrl = "http://localhost:9082";
+
+    public MainWindow()
+    {
+        InitializeComponent();
+        RootGrid.MouseLeftButtonDown += (s, e) =>
+        {
+            if (e.ButtonState == MouseButtonState.Pressed) DragMove();
+        };
+        PetHost.InteractionReceived += OnInteractionReceived;
+        Loaded += OnLoaded;
+        Closed += OnClosed;
+    }
+
+    private void OnLoaded(object sender, RoutedEventArgs e)
+    {
+        var token = TokenStore.Load();
+        _api = new ApiClient(new HttpClient(), _baseUrl, token ?? "dev-token");
+        StartPolling();
+        SetupTray();
+        _ = SyncMascotAsync();
+    }
+
+    private void StartPolling()
+    {
+        _taskTimer = new Timer(30_000) { AutoReset = true };
+        _taskTimer.Elapsed += async (_, _) => await PollTasksAsync();
+        _taskTimer.Start();
+
+        _energyTimer = new Timer(60_000) { AutoReset = true };
+        _energyTimer.Elapsed += async (_, _) => await PollHealthAsync();
+        _energyTimer.Start();
+
+        StartWaterReminder();
+    }
+
+    private async Task PollTasksAsync()
+    {
+        if (_api == null) return;
+        try
+        {
+            var tasks = await _api.GetTodayTasksAsync();
+            var state = StateManager.MapState(tasks ?? new List<CfcTask>(), null, null, false);
+            PushState(state, "tasks");
+        }
+        catch (ApiException ex) { PushBubble($"服务暂时不可用({ex.ApiCode})"); }
+        catch (Exception) { PushBubble("连接后端失败,请检查服务"); }
+    }
+
+    private async Task PollHealthAsync()
+    {
+        if (_api == null) return;
+        try
+        {
+            var energy = await _api.GetEnergyOverviewAsync();
+            var checkin = await _api.GetDailyCheckinAsync();
+            var state = StateManager.MapState(null, energy, checkin, false);
+            PushState(state, "health");
+        }
+        catch (Exception) { /* 静默 */ }
+    }
+
+    private void PushState(PetState state, string reason)
+    {
+        var payload = new PetStatePayload(StateManager.StateToCamelCase(state), reason, null);
+        PetHost.NotifyState(JsonSerializer.Serialize(payload));
+    }
+
+    private void PushBubble(string text)
+    {
+        PetHost.ShowBubble(text, 5000);
+    }
+
+    private void SetupTray()
+    {
+        var menu = new ContextMenuStrip();
+        menu.Items.Add("设置", null, (_, _) => ShowSettings());
+        menu.Items.Add("显示/隐藏", null, (_, _) => ToggleVisible());
+        menu.Items.Add("退出", null, (_, _) => { _trayIcon?.Dispose(); Application.Current.Shutdown(); });
+
+        _trayIcon = new NotifyIcon
+        {
+            Text = "浠艾福桌面宠物",
+            Icon = SystemIcons.Application,
+            ContextMenuStrip = menu,
+            Visible = true
+        };
+        _trayIcon.DoubleClick += (_, _) => ToggleVisible();
+    }
+
+    private void ToggleVisible()
+    {
+        Visibility = Visibility == Visibility.Visible ? Visibility.Hidden : Visibility.Visible;
+    }
+
+    private void ShowSettings()
+    {
+        var win = new SettingsWindow(TokenStore.Load(), _baseUrl) { Owner = this };
+        if (win.ShowDialog() == true)
+        {
+            _baseUrl = win.BaseUrl;
+            if (!string.IsNullOrEmpty(win.Token)) TokenStore.Save(win.Token);
+            _api = new ApiClient(new HttpClient(), _baseUrl, TokenStore.Load() ?? "dev-token");
+            StartPolling();
+        }
+    }
+
+    private async void OnClosed(object? sender, EventArgs e)
+    {
+        _taskTimer?.Stop(); _energyTimer?.Stop(); _waterTimer?.Stop();
+        _taskTimer?.Dispose(); _energyTimer?.Dispose(); _waterTimer?.Dispose();
+        _trayIcon?.Dispose();
+    }
+
+    // ── 喝水提醒 ─────────────────────────────────────────────
+    private void StartWaterReminder()
+    {
+        _waterTimer = new Timer(60 * 60 * 1000) { AutoReset = true };
+        _waterTimer.Elapsed += async (_, _) => await CheckWaterAsync();
+        _waterTimer.Start();
+        _ = CheckWaterAsync();
+    }
+
+    private async Task CheckWaterAsync()
+    {
+        if (_api == null) return;
+        var hour = DateTime.Now.Hour;
+        if (hour < 10 || hour > 20) return;
+        try
+        {
+            var checkin = await _api.GetDailyCheckinAsync();
+            if (checkin != null && !checkin.Items.Contains("water"))
+            {
+                PushState(PetState.WaitingForInteraction, "drink_water");
+                PetHost.ShowBubble("该喝水啦~点我确认完成!", 15000);
+            }
+        }
+        catch (Exception) { }
+    }
+
+    private void OnInteractionReceived(string action, string? dataJson)
+    {
+        switch (action)
+        {
+            case "click":
+                _ = HandleClickAsync();
+                break;
+            case "pet":
+                PetHost.ShowBubble("嘻嘻~", 2000);
+                break;
+        }
+    }
+
+    private async Task HandleClickAsync()
+    {
+        if (_api == null) return;
+        try
+        {
+            var result = await _api.CompleteDrinkWaterAsync();
+            if (result?.IsSuccess == true)
+            {
+                PushState(PetState.Happy, "drink_complete");
+                PetHost.ShowBubble("喝水打卡成功!太棒了~", 4000);
+            }
+        }
+        catch (Exception)
+        {
+            PushBubble("打卡失败,请稍后再试");
+        }
+    }
+
+    // ── 吉祥物同步 ───────────────────────────────────────────
+    private async Task SyncMascotAsync()
+    {
+        if (_api == null) return;
+        try
+        {
+            var mascot = await _api.GetMascotAsync();
+            var petName = mascot?.MascotId ?? "xibao";
+            var cfgPath = Path.Combine(AppContext.BaseDirectory, "DefaultPets", petName, "pet-config.json");
+            if (File.Exists(cfgPath))
+            {
+                var json = File.ReadAllText(cfgPath);
+                var parsed = PetConfig.Parse(json);
+                PetHost.LoadPetConfig(JsonSerializer.Serialize(parsed));
+            }
+        }
+        catch (Exception) { /* 默认浠宝 */ }
+    }
+}

+ 78 - 0
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs

@@ -0,0 +1,78 @@
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+
+namespace XafPet.Desktop.PetEngine;
+
+/// <summary>CFC 后端 API 客户端。所有接口统一 POST + JWT Bearer。</summary>
+public sealed class ApiClient
+{
+    private readonly HttpClient _http;
+    private readonly string _baseUrl;
+    private string _token;
+
+    private static readonly JsonSerializerOptions JsonOpts = new(JsonSerializerDefaults.Web);
+
+    public ApiClient(HttpClient http, string baseUrl, string token)
+    {
+        _http = http;
+        _baseUrl = baseUrl.TrimEnd('/');
+        _token = token;
+    }
+
+    public void UpdateToken(string token) => _token = token;
+
+    private async Task<T?> PostAsync<T>(string path, object? body = null)
+    {
+        using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}{path}");
+        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+        if (body != null)
+        {
+            req.Content = new StringContent(JsonSerializer.Serialize(body, JsonOpts),
+                Encoding.UTF8, "application/json");
+        }
+        var resp = await _http.SendAsync(req);
+        var json = await resp.Content.ReadAsStringAsync();
+        var result = JsonSerializer.Deserialize<ApiResult<T>>(json, JsonOpts)
+            ?? throw new ApiException(-1, "响应解析失败");
+        if (!result.IsSuccess)
+            throw new ApiException(result.Code, result.Message ?? "未知错误");
+        return result.Data;
+    }
+
+    /// <summary>POST /api/tasks/today — 今日任务</summary>
+    public Task<List<CfcTask>?> GetTodayTasksAsync()
+        => PostAsync<List<CfcTask>>("/api/tasks/today", new Dictionary<string, object>());
+
+    /// <summary>POST /api/energy/overview — 五维能量概览</summary>
+    public Task<EnergyOverview?> GetEnergyOverviewAsync()
+        => PostAsync<EnergyOverview>("/api/energy/overview");
+
+    /// <summary>POST /api/daily/checkin — 今日打卡状态</summary>
+    public Task<DailyCheckin?> GetDailyCheckinAsync()
+        => PostAsync<DailyCheckin>("/api/daily/checkin");
+
+    /// <summary>POST /api/daily/checkin — 完成喝水打卡(body: {"type":"water"})</summary>
+    public async Task<ApiResult<object>?> CompleteDrinkWaterAsync()
+    {
+        using var req = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/api/daily/checkin");
+        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _token);
+        req.Content = new StringContent("""{"type":"water"}""", Encoding.UTF8, "application/json");
+        var resp = await _http.SendAsync(req);
+        var json = await resp.Content.ReadAsStringAsync();
+        var result = JsonSerializer.Deserialize<ApiResult<object>>(json, JsonOpts)
+            ?? throw new ApiException(-1, "响应解析失败");
+        if (!result.IsSuccess)
+            throw new ApiException(result.Code, result.Message ?? "未知错误");
+        return result;
+    }
+
+    /// <summary>POST /api/user/mascot/get — 获取用户已选吉祥物</summary>
+    public Task<MascotInfo?> GetMascotAsync()
+        => PostAsync<MascotInfo>("/api/user/mascot/get");
+
+    /// <summary>POST /api/user/mascot/set — 设置吉祥物</summary>
+    public Task<object?> SetMascotAsync(string mascotId)
+        => PostAsync<object>("/api/user/mascot/set", new { mascotId });
+
+}

+ 89 - 0
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs

@@ -0,0 +1,89 @@
+using System.Text.Json.Serialization;
+
+namespace XafPet.Desktop.PetEngine;
+
+/// <summary>CFC 统一响应包装 Result<T></summary>
+public sealed class ApiResult<T>
+{
+    [JsonPropertyName("code")]
+    public int Code { get; set; }
+
+    [JsonPropertyName("message")]
+    public string? Message { get; set; }
+
+    [JsonPropertyName("data")]
+    public T? Data { get; set; }
+
+    public bool IsSuccess => Code == 200;
+}
+
+public sealed class CfcTask
+{
+    [JsonPropertyName("id")]
+    public long Id { get; set; }
+
+    [JsonPropertyName("title")]
+    public string? Title { get; set; }
+
+    [JsonPropertyName("status")]
+    public string? Status { get; set; }   // PENDING / IN_PROGRESS / COMPLETED / REJECTED
+
+    [JsonPropertyName("dueDate")]
+    public string? DueDate { get; set; }
+}
+
+public sealed class DimensionValue
+{
+    [JsonPropertyName("dimension")]
+    public string? Dimension { get; set; }
+
+    [JsonPropertyName("value")]
+    public int Value { get; set; }
+
+    [JsonPropertyName("color")]
+    public string? Color { get; set; }
+}
+
+public sealed class EnergyOverview
+{
+    [JsonPropertyName("dimensions")]
+    public List<DimensionValue> Dimensions { get; set; } = new();
+}
+
+public sealed class DailyCheckin
+{
+    [JsonPropertyName("checked")]
+    public bool Checked { get; set; }
+
+    [JsonPropertyName("items")]
+    public List<string> Items { get; set; } = new();
+}
+
+/// <summary>API 返回非 200 code 时抛出</summary>
+public sealed class ApiException : Exception
+{
+    public int ApiCode { get; }
+
+    public ApiException(int apiCode, string message) : base(message)
+    {
+        ApiCode = apiCode;
+    }
+}
+
+public sealed class MascotInfo
+{
+    [JsonPropertyName("mascotId")]
+    public string? MascotId { get; set; }
+
+    [JsonPropertyName("name")]
+    public string? Name { get; set; }
+
+public sealed class MascotInfo
+{
+    [JsonPropertyName("mascotId")]
+    public string? MascotId { get; set; }
+
+    [JsonPropertyName("name")]
+    public string? Name { get; set; }
+}
+}

+ 73 - 0
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs

@@ -0,0 +1,73 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace XafPet.Desktop.PetEngine;
+
+public enum PetType { Animal, Human, Custom }
+
+/// <summary>宠物形象配置(pet-config.json)</summary>
+public sealed class PetConfig
+{
+    [JsonPropertyName("version")]
+    public string Version { get; set; } = "1.0";
+
+    [JsonPropertyName("type")]
+    public PetType Type { get; set; } = PetType.Custom;
+
+    [JsonPropertyName("species")]
+    public string Species { get; set; } = "other";
+
+    [JsonPropertyName("gender")]
+    public string Gender { get; set; } = "neutral";
+
+    [JsonPropertyName("image_base_url")]
+    public string ImageBaseUrl { get; set; } = "";
+
+    [JsonPropertyName("default_image")]
+    public string DefaultImage { get; set; } = "standing.png";
+
+    [JsonPropertyName("actions")]
+    public Dictionary<string, string> Actions { get; set; } = new();
+
+    [JsonPropertyName("sounds")]
+    public Dictionary<string, string> Sounds { get; set; } = new();
+
+    [JsonPropertyName("parameters")]
+    public PetParameters Parameters { get; set; } = new();
+
+    [JsonIgnore]
+    public int CustomStateCount => Actions.Keys.Count(k => k.StartsWith("custom_state_"));
+
+    /// <summary>解析 JSON 字符串;缺省 actions 时补默认映射(key=value)</summary>
+    public static PetConfig Parse(string json)
+    {
+        var config = JsonSerializer.Deserialize<PetConfig>(json)
+            ?? throw new JsonException("pet-config.json 反序列化结果为 null");
+        if (config.Actions.Count == 0)
+        {
+            config.Actions = new Dictionary<string, string>
+            {
+                ["idle"] = "idle",
+                ["happy"] = "happy",
+                ["busy"] = "busy",
+                ["sleeping"] = "sleeping"
+            };
+        }
+        return config;
+    }
+
+    public string GetActionForState(string stateName)
+        => Actions.TryGetValue(stateName, out var v) ? v : stateName;
+}
+
+public sealed class PetParameters
+{
+    [JsonPropertyName("move_distance")]
+    public int MoveDistance { get; set; } = 15;
+
+    [JsonPropertyName("animation_speed")]
+    public double AnimationSpeed { get; set; } = 1.0;
+
+    [JsonPropertyName("interaction_timeout")]
+    public int InteractionTimeout { get; set; } = 300; // 秒
+}

+ 27 - 0
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs

@@ -0,0 +1,27 @@
+namespace XafPet.Desktop.PetEngine;
+
+/// <summary>宠物核心状态枚举(C# 侧定义,JS 侧同名接收)</summary>
+public enum PetState
+{
+    Idle,                  // 默认
+    Happy,                 // 任务全完成
+    Busy,                  // 有任务进行中
+    Worried,               // 任务超期未完成
+    Excited,               // 健康打卡完成
+    Remind,                // 提醒用户(喝水/任务)
+    Energetic,             // 身维度能量高
+    Tired,                 // 身维度能量低
+    Curious,               // 智维度能量高
+    Sad,                   // 全维度低
+    Sleeping,              // 用户无操作 >15min
+    Watching,              // 用户活跃,注视光标
+    WaitingForInteraction, // 等待用户互动(打卡确认)
+    MiniGame               // 小游戏模式
+}
+
+/// <summary>推送 WebView 的状态负载</summary>
+public sealed record PetStatePayload(
+    string State,           // 枚举名 camelCase:如 "waitingForInteraction"
+    string? Reason,         // 可选:如 "drink_water"、"task_complete"
+    object? Data            // 可选:任务列表/能量值等
+);

+ 62 - 0
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs

@@ -0,0 +1,62 @@
+using System.Text.RegularExpressions;
+
+namespace XafPet.Desktop.PetEngine;
+
+/// <summary>把 CFC API 数据转换为宠物状态(纯逻辑,无 UI 依赖)</summary>
+public static class StateManager
+{
+    /// <summary>核心状态映射。优先级:Sleeping > Excited > Happy > Worried > Busy > Sad > Tired/Energetic/Curious > Idle</summary>
+    public static PetState MapState(
+        List<CfcTask>? tasks,
+        EnergyOverview? energy,
+        DailyCheckin? checkin,
+        bool userInactive)
+    {
+        // 1. 用户无操作 → 睡眠(最高优先级,除非有未完成任务需要提醒)
+        var hasPendingTasks = tasks?.Any(t => t.Status is "PENDING" or "IN_PROGRESS") ?? false;
+        if (userInactive && !hasPendingTasks) return PetState.Sleeping;
+
+        // 2. 健康打卡完成 → 兴奋(次高)
+        if (checkin is { Checked: true }) return PetState.Excited;
+
+        // 3. 任务状态
+        if (tasks is { Count: > 0 })
+        {
+            var allDone = tasks.All(t => t.Status == "COMPLETED");
+            if (allDone) return PetState.Happy;
+
+            // 超期未完成(dueDate 早于今天且非完成态)→ 担忧
+            var today = DateOnly.FromDateTime(DateTime.Today);
+            var overdue = tasks.Any(t =>
+                t.Status is "PENDING" or "IN_PROGRESS" &&
+                DateOnly.TryParse(t.DueDate, out var due) && due < today);
+            if (overdue) return PetState.Worried;
+
+            return PetState.Busy;
+        }
+
+        // 4. 五维能量
+        if (energy is { Dimensions.Count: > 0 })
+        {
+            var dims = energy.Dimensions;
+            var allLow = dims.All(d => d.Value < 30);
+            if (allLow) return PetState.Sad;
+
+            var body = dims.FirstOrDefault(d => d.Dimension == "身");
+            if (body != null && body.Value < 30) return PetState.Tired;
+            if (body != null && body.Value >= 85) return PetState.Energetic;
+
+            var mind = dims.FirstOrDefault(d => d.Dimension == "智");
+            if (mind != null && mind.Value >= 85) return PetState.Curious;
+        }
+
+        return PetState.Idle;
+    }
+
+    /// <summary>枚举 → camelCase(JS 侧状态名)。WaitingForInteraction → waitingForInteraction</summary>
+    public static string StateToCamelCase(PetState state)
+    {
+        var name = state.ToString();
+        return Regex.Replace(name, "(^[A-Z])", m => m.Value.ToLowerInvariant());
+    }
+}

+ 11 - 0
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml

@@ -0,0 +1,11 @@
+<UserControl x:Class="XafPet.Desktop.PetEngine.WebViewHost"
+             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+             xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
+             Background="Transparent">
+    <Grid>
+        <wv2:WebView2 x:Name="PetWebView"
+                       DefaultBackgroundColor="Transparent"
+                       HorizontalAlignment="Stretch" VerticalAlignment="Stretch"/>
+    </Grid>
+</UserControl>

+ 77 - 0
cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml.cs

@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using System.Windows.Controls;
+using Microsoft.Web.WebView2.Core;
+
+namespace XafPet.Desktop.PetEngine;
+
+public partial class WebViewHost : UserControl
+{
+    public event Action<string, string?>? InteractionReceived;
+
+    public WebViewHost()
+    {
+        InitializeComponent();
+        Loaded += async (_, _) => await InitializeWebViewAsync();
+    }
+
+    private async Task InitializeWebViewAsync()
+    {
+        var env = await CoreWebView2Environment.CreateAsync(
+            userDataFolder: Path.Combine(
+                Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+                "XafPet", "WebView2"));
+        await PetWebView.EnsureCoreWebView2Async(env);
+
+        PetWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
+        PetWebView.CoreWebView2.Settings.AreDevToolsEnabled = true;
+        PetWebView.CoreWebView2.Settings.IsStatusBarEnabled = false;
+
+        PetWebView.CoreWebView2.AddHostObjectToScript("cfcHost", new CfcHostBridge(this));
+
+        var htmlPath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "Assets", "pet.html"));
+        PetWebView.CoreWebView2.Navigate($"file:///{htmlPath.Replace('\\', '/')}");
+    }
+
+    /// <summary>C# → JS:推送宠物状态</summary>
+    public void NotifyState(string stateJson)
+    {
+        PetWebView.ExecuteScriptAsync($"window.cfcPetAPI?.notifyState({stateJson})");
+    }
+
+    /// <summary>C# → JS:推送自定义状态</summary>
+    public void SetCustomState(string stateName, string dataJson)
+    {
+        PetWebView.ExecuteScriptAsync(
+            $"window.cfcPetAPI?.setCustomState('{stateName}', {dataJson})");
+    }
+
+    /// <summary>C# → JS:显示气泡</summary>
+    public void ShowBubble(string text, int durationMs = 5000)
+    {
+        var escaped = text.Replace("\\", "\\\\").Replace("'", "\\'");
+        PetWebView.ExecuteScriptAsync(
+            $"window.cfcPetAPI?.showBubble('{escaped}', {durationMs})");
+    }
+
+    /// <summary>C# → JS:加载配置</summary>
+    public void LoadPetConfig(string configJson)
+    {
+        PetWebView.ExecuteScriptAsync($"window.cfcPetAPI?.loadConfig({configJson})");
+    }
+
+    internal void RaiseInteraction(string action, string? dataJson)
+        => InteractionReceived?.Invoke(action, dataJson);
+}
+
+/// <summary>暴露给 JS 的 C# 宿主对象(window.chrome.webview.hostObjects.sync.cfcHost)</summary>
+internal sealed class CfcHostBridge
+{
+    private readonly WebViewHost _host;
+    public CfcHostBridge(WebViewHost host) => _host = host;
+    public void onInteract(string action, string dataJson)
+        => _host.RaiseInteraction(action, dataJson);
+    public void onDragStart() { }
+    public void onDragEnd() { }
+}

+ 20 - 0
cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml

@@ -0,0 +1,20 @@
+<Window x:Class="XafPet.Desktop.SettingsWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        Title="浠艾福桌面宠物 - 设置"
+        Width="360" Height="340"
+        WindowStartupLocation="CenterScreen">
+    <StackPanel Margin="20">
+        <TextBlock Text="后端地址" FontWeight="SemiBold" Margin="0,0,0,6"/>
+        <TextBox x:Name="BaseUrlBox" Text="http://localhost:9082" Padding="6"/>
+
+        <TextBlock Text="JWT Token" FontWeight="SemiBold" Margin="0,16,0,6"/>
+        <PasswordBox x:Name="TokenBox" Padding="6"/>
+
+        <TextBlock Text="宠物形象" FontWeight="SemiBold" Margin="0,16,0,6"/>
+        <ComboBox x:Name="PetCombo" Padding="6" MinWidth="120"/>
+
+        <Button Content="保存" Click="OnSaveClick" Margin="0,24,0,0" Padding="10,6"
+                HorizontalAlignment="Right" Width="100"/>
+    </StackPanel>
+</Window>

+ 44 - 0
cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml.cs

@@ -0,0 +1,44 @@
+using System.IO;
+using System.Text.Json;
+using System.Windows;
+using XafPet.Desktop.PetEngine;
+
+namespace XafPet.Desktop;
+
+public partial class SettingsWindow : Window
+{
+    public SettingsWindow(string? currentToken, string currentBaseUrl)
+    {
+        InitializeComponent();
+        BaseUrlBox.Text = currentBaseUrl;
+        if (!string.IsNullOrEmpty(currentToken)) TokenBox.Password = currentToken;
+        LoadPets();
+    }
+
+    private void LoadPets()
+    {
+        var dirs = new List<string>();
+        var builtin = Path.Combine(AppContext.BaseDirectory, "DefaultPets");
+        if (Directory.Exists(builtin)) dirs.AddRange(Directory.GetDirectories(builtin));
+        var userDir = Path.Combine(
+            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+            "XafPet", "pets");
+        if (Directory.Exists(userDir)) dirs.AddRange(Directory.GetDirectories(userDir));
+
+        foreach (var d in dirs)
+        {
+            if (File.Exists(Path.Combine(d, "pet-config.json")))
+                PetCombo.Items.Add(Path.GetFileName(d));
+        }
+        if (PetCombo.Items.Count > 0) PetCombo.SelectedIndex = 0;
+    }
+
+    public string BaseUrl => BaseUrlBox.Text.Trim();
+    public string? Token => TokenBox.Password.Length > 0 ? TokenBox.Password : null;
+
+    private void OnSaveClick(object sender, RoutedEventArgs e)
+    {
+        DialogResult = true;
+        Close();
+    }
+}

+ 23 - 0
cfc-desktop-pet/src/XafPet.Desktop/SingleInstanceHelper.cs

@@ -0,0 +1,23 @@
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace XafPet.Desktop;
+
+/// <summary>简单的单实例检测(按进程名匹配)</summary>
+public static class SingleInstanceHelper
+{
+    [DllImport("user32.dll")]
+    private static extern bool SetForegroundWindow(IntPtr hWnd);
+
+    public static bool TryEnterFirstInstance()
+    {
+        var name = System.Diagnostics.Process.GetCurrentProcess().ProcessName;
+        var instances = System.Diagnostics.Process.GetProcessesByName(name);
+        if (instances.Length > 1)
+        {
+            SetForegroundWindow(instances[0].MainWindowHandle);
+            return false;
+        }
+        return true;
+    }
+}

+ 43 - 0
cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs

@@ -0,0 +1,43 @@
+using System.IO;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace XafPet.Desktop;
+
+public static class TokenStore
+{
+    private static string _path = Path.Combine(
+        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+        "XafPet", "token.bin");
+
+    public static void SetPath(string path) => _path = path;
+
+    public static void Save(string token)
+    {
+        Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
+        var encrypted = ProtectedData.Protect(
+            Encoding.UTF8.GetBytes(token), null, DataProtectionScope.CurrentUser);
+        File.WriteAllBytes(_path, encrypted);
+    }
+
+    public static string? Load()
+    {
+        if (!File.Exists(_path)) return null;
+        try
+        {
+            var encrypted = File.ReadAllBytes(_path);
+            var decrypted = ProtectedData.Unprotect(
+                encrypted, null, DataProtectionScope.CurrentUser);
+            return Encoding.UTF8.GetString(decrypted);
+        }
+        catch (CryptographicException)
+        {
+            return null;
+        }
+    }
+
+    public static void Clear()
+    {
+        if (File.Exists(_path)) File.Delete(_path);
+    }
+}

+ 28 - 0
cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj

@@ -0,0 +1,28 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>WinExe</OutputType>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <Nullable>enable</Nullable>
+    <UseWPF>true</UseWPF>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <AssemblyName>XafPet.Desktop</AssemblyName>
+    <RootNamespace>XafPet.Desktop</RootNamespace>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2210.55" />
+  </ItemGroup>
+  <ItemGroup>
+    <FrameworkReference Include="Microsoft.WindowsDesktop.App.WindowsForms" />
+  </ItemGroup>
+  <ItemGroup>
+    <None Include="Assets\**" CopyToOutputDirectory="PreserveNewest" />
+    <None Include="DefaultPets\**" CopyToOutputDirectory="PreserveNewest" />
+  </ItemGroup>
+  <!-- 发布配置(单文件 exe,Self-Contained) -->
+  <PropertyGroup>
+    <PublishSingleFile>true</PublishSingleFile>
+    <SelfContained>true</SelfContained>
+    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
+    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
+  </PropertyGroup>
+</Project>

+ 96 - 0
cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs

@@ -0,0 +1,96 @@
+using System.Net;
+using System.Text.Json;
+using Moq;
+using Moq.Protected;
+using XafPet.Desktop.PetEngine;
+using Xunit;
+
+namespace XafPet.Desktop.Tests;
+
+public class ApiClientTests
+{
+    private static ApiClient CreateClientWithResponse(HttpStatusCode code, string json, string authToken = "test-token")
+    {
+        var handler = new Mock<HttpMessageHandler>();
+        handler.Protected()
+            .Setup<Task<HttpResponseMessage>>("SendAsync",
+                ItExpr.IsAny<HttpRequestMessage>(),
+                ItExpr.IsAny<CancellationToken>())
+            .ReturnsAsync(new HttpResponseMessage(code)
+            {
+                Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json")
+            });
+        var http = new HttpClient(handler.Object);
+        return new ApiClient(http, "http://localhost:9082", authToken);
+    }
+
+    [Fact]
+    public async Task GetTodayTasks_ReturnsTaskList()
+    {
+        var json = """
+        { "code": 200, "message": "ok", "data": [
+            { "id": 1, "title": "喝水", "status": "PENDING", "dueDate": "2026-10-10" }
+        ] }
+        """;
+        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
+
+        var tasks = await client.GetTodayTasksAsync();
+
+        Assert.Single(tasks);
+        Assert.Equal("喝水", tasks[0].Title);
+        Assert.Equal("PENDING", tasks[0].Status);
+    }
+
+    [Fact]
+    public async Task GetEnergyOverview_ReturnsDimensions()
+    {
+        var json = """
+        { "code": 200, "message": "ok", "data": {
+            "dimensions": [
+              { "dimension": "身", "value": 80, "color": "#FF8C42" },
+              { "dimension": "智", "value": 45, "color": "#6366F1" }
+            ]
+        } }
+        """;
+        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
+
+        var overview = await client.GetEnergyOverviewAsync();
+
+        Assert.Equal(2, overview.Dimensions.Count);
+        Assert.Equal(80, overview.Dimensions[0].Value);
+    }
+
+    [Fact]
+    public async Task GetDailyCheckin_ReturnsStatus()
+    {
+        var json = """
+        { "code": 200, "message": "ok", "data": { "checked": true, "items": ["water", "exercise"] } }
+        """;
+        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
+
+        var status = await client.GetDailyCheckinAsync();
+
+        Assert.True(status.Checked);
+        Assert.Contains("water", status.Items);
+    }
+
+    [Fact]
+    public async Task CompleteDrinkWater_PostsCorrectEndpoint()
+    {
+        var json = """{ "code": 200, "message": "打卡成功", "data": null }""";
+        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
+
+        var result = await client.CompleteDrinkWaterAsync();
+
+        Assert.Equal("打卡成功", result.Message);
+    }
+
+    [Fact]
+    public async Task NonSuccessCode_ThrowsApiException()
+    {
+        var json = """{ "code": 500, "message": "服务器错误", "data": null }""";
+        var client = CreateClientWithResponse(HttpStatusCode.OK, json);
+
+        await Assert.ThrowsAsync<ApiException>(() => client.GetTodayTasksAsync());
+    }
+}

+ 68 - 0
cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs

@@ -0,0 +1,68 @@
+using System.Text.Json;
+using XafPet.Desktop.PetEngine;
+using Xunit;
+
+namespace XafPet.Desktop.Tests;
+
+public class PetConfigTests
+{
+    private const string ValidJson = """
+    {
+      "version": "1.0",
+      "type": "animal",
+      "species": "cockatiel",
+      "gender": "female",
+      "image_base_url": "./DefaultPets/xibao/",
+      "default_image": "standing.png",
+      "actions": {
+        "idle": "wings_idle",
+        "happy": "wings_spread",
+        "custom_state_1": "user_animation_1"
+      },
+      "parameters": {
+        "move_distance": 15,
+        "animation_speed": 1.0,
+        "interaction_timeout": 300
+      }
+    }
+    """;
+
+    [Fact]
+    public void Parse_ValidJson_LoadsAllFields()
+    {
+        var config = PetConfig.Parse(ValidJson);
+
+        Assert.Equal("1.0", config.Version);
+        Assert.Equal(PetType.Animal, config.Type);
+        Assert.Equal("cockatiel", config.Species);
+        Assert.Equal("female", config.Gender);
+        Assert.Equal("./DefaultPets/xibao/", config.ImageBaseUrl);
+        Assert.Equal("standing.png", config.DefaultImage);
+        Assert.Equal("wings_idle", config.Actions["idle"]);
+        Assert.Equal(15, config.Parameters.MoveDistance);
+    }
+
+    [Fact]
+    public void Parse_MissingActionsKey_UsesDefaults()
+    {
+        var json = """{ "version": "1.0", "type": "human", "image_base_url": "./pets/", "default_image": "a.png" }""";
+        var config = PetConfig.Parse(json);
+
+        Assert.Equal("idle", config.Actions["idle"]);
+    }
+
+    [Fact]
+    public void Parse_InvalidJson_ThrowsJsonException()
+    {
+        Assert.Throws<JsonException>(() => PetConfig.Parse("{ not valid json"));
+    }
+
+    [Fact]
+    public void Parse_CustomStates_CountedCorrectly()
+    {
+        var config = PetConfig.Parse(ValidJson);
+
+        Assert.Equal(1, config.CustomStateCount);
+        Assert.Equal("user_animation_1", config.GetActionForState("custom_state_1"));
+    }
+}

+ 8 - 0
cfc-desktop-pet/tests/XafPet.Desktop.Tests/README.md

@@ -0,0 +1,8 @@
+```json
+{
+  "version": "1.0",
+  "type": "project",
+  "name": "XafPet.Desktop",
+  "description": "Desktop pet application scaffold for XafPet (Care Family)"
+}
+```

+ 107 - 0
cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs

@@ -0,0 +1,107 @@
+using XafPet.Desktop.PetEngine;
+using Xunit;
+
+namespace XafPet.Desktop.Tests;
+
+public class StateManagerTests
+{
+    [Fact]
+    public void AllTasksCompleted_ReturnsHappy()
+    {
+        var tasks = new List<CfcTask>
+        {
+            new() { Id = 1, Title = "喝水", Status = "COMPLETED" },
+            new() { Id = 2, Title = "运动", Status = "COMPLETED" }
+        };
+        var state = StateManager.MapState(tasks, null, null, false);
+        Assert.Equal(PetState.Happy, state);
+    }
+
+    [Fact]
+    public void SomeTasksPending_ReturnsBusy()
+    {
+        var tasks = new List<CfcTask>
+        {
+            new() { Id = 1, Title = "喝水", Status = "COMPLETED" },
+            new() { Id = 2, Title = "运动", Status = "PENDING" }
+        };
+        var state = StateManager.MapState(tasks, null, null, false);
+        Assert.Equal(PetState.Busy, state);
+    }
+
+    [Fact]
+    public void NoTasks_ReturnsIdle()
+    {
+        var state = StateManager.MapState(new List<CfcTask>(), null, null, false);
+        Assert.Equal(PetState.Idle, state);
+    }
+
+    [Fact]
+    public void CheckinDone_OverridesToExcited()
+    {
+        var state = StateManager.MapState(new List<CfcTask>(), null,
+            new DailyCheckin { Checked = true, Items = new List<string> { "water" } }, false);
+        Assert.Equal(PetState.Excited, state);
+    }
+
+    [Fact]
+    public void LowBodyEnergy_ReturnsTired()
+    {
+        var energy = new EnergyOverview
+        {
+            Dimensions = new List<DimensionValue>
+            {
+                new() { Dimension = "身", Value = 20, Color = "#FF8C42" },
+                new() { Dimension = "智", Value = 80, Color = "#6366F1" }
+            }
+        };
+        var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
+        Assert.Equal(PetState.Tired, state);
+    }
+
+    [Fact]
+    public void AllDimensionsLow_ReturnsSad()
+    {
+        var energy = new EnergyOverview
+        {
+            Dimensions = new List<DimensionValue>
+            {
+                new() { Dimension = "身", Value = 15, Color = "#FF8C42" },
+                new() { Dimension = "智", Value = 20, Color = "#6366F1" },
+                new() { Dimension = "富", Value = 25, Color = "#F59E0B" }
+            }
+        };
+        var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
+        Assert.Equal(PetState.Sad, state);
+    }
+
+    [Fact]
+    public void HighBodyEnergy_ReturnsEnergetic()
+    {
+        var energy = new EnergyOverview
+        {
+            Dimensions = new List<DimensionValue>
+            {
+                new() { Dimension = "身", Value = 90, Color = "#FF8C42" }
+            }
+        };
+        var state = StateManager.MapState(new List<CfcTask>(), energy, null, false);
+        Assert.Equal(PetState.Energetic, state);
+    }
+
+    [Fact]
+    public void UserInactive_ReturnsSleeping()
+    {
+        var state = StateManager.MapState(new List<CfcTask>(), null, null, true);
+        Assert.Equal(PetState.Sleeping, state);
+    }
+
+    [Fact]
+    public void StateToCamelCase_ConvertsEnumName()
+    {
+        Assert.Equal("waitingForInteraction",
+            StateManager.StateToCamelCase(PetState.WaitingForInteraction));
+        Assert.Equal("miniGame", StateManager.StateToCamelCase(PetState.MiniGame));
+        Assert.Equal("idle", StateManager.StateToCamelCase(PetState.Idle));
+    }
+}

+ 17 - 0
cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj

@@ -0,0 +1,17 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <TargetFramework>net6.0-windows</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <IsPackable>false</IsPackable>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
+    <PackageReference Include="xunit" Version="2.5.3" />
+    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
+    <PackageReference Include="Moq" Version="4.20.69" />
+  </ItemGroup>
+  <ItemGroup>
+    <ProjectReference Include="..\..\src\XafPet.Desktop\XafPet.Desktop.csproj" />
+  </ItemGroup>
+</Project>