Sfoglia il codice sorgente

Merge remote changes and resolve conflicts

liaoxg 20 ore fa
parent
commit
379184e3af
68 ha cambiato i file con 5334 aggiunte e 649 eliminazioni
  1. 24 9
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  3. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
  4. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/TaskReminderConfigDTO.java
  5. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  6. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskReminderConfig.java
  7. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java
  8. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java
  9. 35 1
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  10. 17 10
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  11. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskReminderConfigService.java
  12. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  13. 6 0
      cfc-backend/src/main/resources/application.yml
  14. 30 19
      cfc-desktop-pet/README.md
  15. 24 0
      cfc-desktop-pet/XafPet.Desktop.sln
  16. 10 0
      cfc-desktop-pet/src-tauri/src/api/mod.rs
  17. 50 0
      cfc-desktop-pet/src-tauri/src/api/models.rs
  18. 93 0
      cfc-desktop-pet/src-tauri/src/commands.rs
  19. 1 0
      cfc-desktop-pet/src-tauri/src/main.rs
  20. 6 0
      cfc-desktop-pet/src/XafPet.Desktop/App.xaml
  21. 16 0
      cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs
  22. 46 0
      cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css
  23. 51 0
      cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js
  24. 105 0
      cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js
  25. 30 0
      cfc-desktop-pet/src/XafPet.Desktop/Assets/pet.html
  26. 27 0
      cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json
  27. 27 0
      cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json
  28. 16 0
      cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml
  29. 207 0
      cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs
  30. 78 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs
  31. 89 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs
  32. 73 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs
  33. 27 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs
  34. 62 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs
  35. 11 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml
  36. 77 0
      cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml.cs
  37. 20 0
      cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml
  38. 44 0
      cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml.cs
  39. 23 0
      cfc-desktop-pet/src/XafPet.Desktop/SingleInstanceHelper.cs
  40. 43 0
      cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs
  41. 28 0
      cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj
  42. 34 0
      cfc-desktop-pet/src/settings.html
  43. 96 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs
  44. 68 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs
  45. 8 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/README.md
  46. 107 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs
  47. 17 0
      cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj
  48. 301 152
      cfc-frontend/components/address-picker.vue
  49. 156 168
      cfc-frontend/pages/index-home/index.vue
  50. 11 9
      cfc-frontend/pages/invite/join-form.vue
  51. 54 8
      cfc-frontend/pages/invite/join.vue
  52. 1 1
      cfc-frontend/pages/profile/service-role-apply.vue
  53. 47 206
      cfc-frontend/pages/reminder-config/index.vue
  54. 3 20
      cfc-frontend/pages/vendor/product-edit/product-edit.vue
  55. 9 2
      cfc-frontend/utils/api.js
  56. 1 1
      cfc-web/.last_build_commit
  57. 2 2
      cfc-web/package-lock.json
  58. 1 1
      cfc-web/package.json
  59. 133 0
      cfc-web/public/CHANGELOG-v1.0.md
  60. 134 1
      cfc-web/public/CHANGELOG.md
  61. 18 0
      cfc-web/src/api/taskReminderConfig.js
  62. 87 0
      cfc-web/src/views/Users.vue
  63. 16 7
      cfc-web/src/views/admin/ActivityEdit.vue
  64. 16 7
      cfc-web/src/views/admin/ArticleEdit.vue
  65. 25 24
      cfc-web/src/views/admin/ProductEdit.vue
  66. 1 1
      cfc-web/src/views/admin/ProductManage.vue
  67. 2112 0
      docs/superpowers/plans/2026-10-10-desktop-pet.md
  68. 353 0
      docs/superpowers/specs/2026-10-10-desktop-pet-design.md

+ 24 - 9
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11503,25 +11503,39 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         log.info("迁移345: health_plans.total_days 已确保存在");
     }
 
+    /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */
     /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */
     private void seedTaskReminderTemplate() {
         try {
             Integer cnt = jdbcTemplate.queryForObject(
                     "SELECT COUNT(*) FROM notification_templates WHERE type = ?",
                     Integer.class, "task_reminder");
-            if (cnt != null && cnt > 0) {
-                log.info("迁移346: task_reminder 通知模板已存在,跳过");
-                return;
+            if (cnt != null && cnt == 0) {
+                jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
+                        "task_reminder",
+                        "任务待完成提醒",
+                        "任务「{title}」{status},完成后记得打卡,可获得积分与 CF 值奖励。",
+                        "in_app,sms");
+                log.info("迁移346: task_reminder 通知模板已插入");
             }
-            jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
-                    "task_reminder",
-                    "任务待完成提醒",
-                    "任务「{title}」{status},完成后记得打卡,可获得积分与 CF 值奖励。",
-                    "in_app,sms");
-            log.info("迁移346: task_reminder 通知模板已插入");
         } catch (Exception e) {
             log.warn("迁移346: 插入 task_reminder 通知模板失败: " + e.getMessage());
         }
+        try {
+            Integer c2 = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM notification_templates WHERE type = ?",
+                    Integer.class, "task_completion_praise");
+            if (c2 != null && c2 == 0) {
+                jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
+                        "task_completion_praise",
+                        "任务完成,太棒了!",
+                        "太棒了!任务「{title}」已完成,继续保持,加油~",
+                        "in_app,wechat");
+                log.info("迁移346+: task_completion_praise 通知模板已插入");
+            }
+        } catch (Exception e) {
+            log.warn("迁移346+: 插入 task_completion_praise 通知模板失败: " + e.getMessage());
+        }
     }
 
     /**
@@ -11807,6 +11821,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                     "UNIQUE KEY uk_user (user_id)" +
                     ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务提醒配置表'");
             log.info("迁移353: 已创建 task_reminder_config 表");
+            try { ensureColumn("task_reminder_config", "completion_praise_enabled", "TINYINT DEFAULT 0 COMMENT '任务正常完成时是否发送表扬鼓励消息 1=启用 0=停用'"); ensureColumn("task_reminder_config", "completion_praise_channels", "VARCHAR(100) DEFAULT 'wechat,in_app' COMMENT '完成表扬渠道(逗号分隔)'"); } catch (Exception ex) { log.warn("迁移353补充列失败: {}", ex.getMessage()); }
         } catch (Exception e) {
             log.warn("迁移353: 创建 task_reminder_config 表失败: {}", e.getMessage());
         }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java

@@ -42,6 +42,9 @@ private static final String[] PUBLIC_PATHS = {
         "/api/auth/check-phone",
         "/api/auth/direct-register",
         "/api/auth/register-with-invite",
+        "/api/auth/wechat-web/qr",
+        "/api/auth/wechat-web/callback",
+        "/api/auth/wechat-web/poll",
         "/api/internal/",
         "/api/product/list",
         "/api/product/detail",

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -102,6 +102,9 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/auth/check-phone",
                 "/api/auth/direct-register",
                 "/api/auth/register-with-idcard",
+                "/api/auth/wechat-web/qr",
+                "/api/auth/wechat-web/callback",
+                "/api/auth/wechat-web/poll",
                 "/api/admin-auth/send-code",
                 "/api/admin-auth/login",
                 "/api/admin-auth/login-by-password",
@@ -145,6 +148,9 @@ public class WebConfig implements WebMvcConfigurer {
                 "/api/auth/check-phone",
                 "/api/auth/direct-register",
                 "/api/auth/register-with-idcard",
+                "/api/auth/wechat-web/qr",
+                "/api/auth/wechat-web/callback",
+                "/api/auth/wechat-web/poll",
                 "/api/admin-auth/send-code",
                 "/api/admin-auth/login",
                 "/api/admin-auth/login-by-password",

+ 4 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/TaskReminderConfigDTO.java

@@ -21,6 +21,10 @@ public class TaskReminderConfigDTO implements Serializable {
     private Integer enabled;
 
     /** 各渠道开通状态(供配置页展示"未开通") */
+    private Integer completionPraiseEnabled;
+
+    private List<String> completionPraiseChannels;
+
     private List<ChannelInfo> availableChannels;
 
     @Data

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -34,7 +34,9 @@ public class Product implements Serializable {
     private String productType;
     private String growthCategory;
     private Long vendorId;
+
     private String vendorName;
+
     private String status;
     private String rejectReason;
     private Integer memberPrice; // 会员价(分)

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskReminderConfig.java

@@ -32,5 +32,11 @@ public class TaskReminderConfig implements Serializable {
 
     private Date createdAt;
 
+    /** 任务正常完成时是否发送表扬鼓励消息 1=启用 0=停用 */
+    private Integer completionPraiseEnabled;
+
+    /** 完成表扬渠道(逗号分隔): in_app/wechat/sms/phone/email */
+    private String completionPraiseChannels;
+
     private Date updatedAt;
 }

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java

@@ -6,9 +6,11 @@ import com.etotem.cfc.dto.FamilyStatusDTO;
 import com.etotem.cfc.dto.InviteValidateDTO;
 import com.etotem.cfc.entity.Family;
 import com.etotem.cfc.entity.FamilyInvitation;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyInvitationMapper;
 import com.etotem.cfc.mapper.FamilyMapper;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import org.springframework.stereotype.Service;
 
@@ -26,6 +28,9 @@ public class FamilyInvitationService {
     @Resource
     private FamilyMapper familyMapper;
 
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
     @Resource
     private UserMapper userMapper;
 
@@ -172,6 +177,30 @@ public class FamilyInvitationService {
         user.setUpdatedAt(new Date());
         userMapper.updateById(user);
 
+        // 【修复】接受邀请后确保该用户在目标家庭有 FamilyMember 记录,成员列表才能显示头像
+        // 1) mergeFamily 切换路径:switchMemberToFamily 已迁移记录,此处跳过避免重复
+        // 2) 无记录路径:创建记录,头像优先取用户本人头像,为空回退默认头像
+        FamilyMember existing = familyMemberMapper.selectOne(
+                new LambdaQueryWrapper<FamilyMember>()
+                        .eq(FamilyMember::getFamilyId, invitation.getFamilyId())
+                        .eq(FamilyMember::getUserId, user.getId())
+                        .last("LIMIT 1"));
+        if (existing == null) {
+            String memberAvatar = (user.getAvatar() != null && !user.getAvatar().isEmpty())
+                    ? user.getAvatar() : "/static/avatar.jpg";
+            FamilyMember newMember = new FamilyMember();
+            newMember.setFamilyId(invitation.getFamilyId());
+            newMember.setUserId(user.getId());
+            newMember.setNickname(user.getNickname());
+            newMember.setAvatar(memberAvatar);
+            newMember.setGender(user.getGender());
+            newMember.setShowToFamily(1);
+            newMember.setTotalPoints(0);
+            newMember.setCreatedAt(new Date());
+            newMember.setUpdatedAt(new Date());
+            familyMemberMapper.insert(newMember);
+        }
+
         return "加入家庭成功";
     }
 

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java

@@ -187,6 +187,29 @@ public class FamilyJoinRequestService {
                 user.setFamilyRole(request.getFamilyRole());
                 user.setUpdatedAt(new Date());
                 userMapper.updateById(user);
+
+                // 【修复】审批通过加入且用户无家庭记录:创建 FamilyMember 确保成员列表显示头像
+                // 头像优先取用户本人头像,为空回退默认头像
+                FamilyMember existing = familyMemberMapper.selectOne(
+                        new LambdaQueryWrapper<FamilyMember>()
+                                .eq(FamilyMember::getFamilyId, family.getId())
+                                .eq(FamilyMember::getUserId, user.getId())
+                                .last("LIMIT 1"));
+                if (existing == null) {
+                    String memberAvatar = (user.getAvatar() != null && !user.getAvatar().isEmpty())
+                            ? user.getAvatar() : "/static/avatar.jpg";
+                    FamilyMember newMember = new FamilyMember();
+                    newMember.setFamilyId(family.getId());
+                    newMember.setUserId(user.getId());
+                    newMember.setNickname(user.getNickname());
+                    newMember.setAvatar(memberAvatar);
+                    newMember.setGender(user.getGender());
+                    newMember.setShowToFamily(1);
+                    newMember.setTotalPoints(0);
+                    newMember.setCreatedAt(new Date());
+                    newMember.setUpdatedAt(new Date());
+                    familyMemberMapper.insert(newMember);
+                }
             }
         }
 

+ 35 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -675,7 +675,41 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
                         .eq(FamilyMember::getUserId, userId)
                         .last("LIMIT 1"));
         if (member == null) {
-            throw new RuntimeException("未找到该用户的家庭成员记录");
+            // 兼容历史孤儿数据:users.family_id 指向旧家庭但 family_members 无该用户成员记录
+            // (早期创建家庭路径漏建创建者成员记录)。旧家庭无能量值资产(按 member_id 关联必然无),
+            // 健康报告按 user_id 关联可能残留,先尝试迁移;再在目标家庭补建成员记录,后续流程自然兼容。
+            try {
+                List<HealthReport> orphanReports = healthReportMapper.selectList(
+                        new LambdaQueryWrapper<HealthReport>()
+                                .eq(HealthReport::getFamilyId, oldFamilyId)
+                                .and(w -> w.eq(HealthReport::getUserId, userId)
+                                        .or().eq(HealthReport::getSubjectId, userId)));
+                for (HealthReport r : orphanReports) {
+                    r.setFamilyId(targetFamilyId);
+                    healthReportMapper.updateById(r);
+                }
+            } catch (Exception e) {
+                log.warn("孤儿成员健康报告迁移异常: userId={}, oldFamilyId={}", userId, oldFamilyId, e);
+            }
+
+            FamilyMember newMember = new FamilyMember();
+            newMember.setFamilyId(targetFamilyId);
+            newMember.setUserId(userId);
+            newMember.setNickname((user.getNickname() != null && !user.getNickname().isEmpty())
+                    ? user.getNickname() : ("用户" + userId));
+            newMember.setPhone(user.getPhone());
+            newMember.setAvatar(user.getAvatar());
+            newMember.setGender(user.getGender());
+            newMember.setGeneration(0);
+            newMember.setTotalPoints(0);
+            newMember.setSystemPoints(0);
+            newMember.setStatus("active");
+            newMember.setCreatedAt(new Date());
+            newMember.setUpdatedAt(new Date());
+            familyMemberMapper.insert(newMember);
+            member = newMember;
+            log.warn("成员记录缺失,已在目标家庭补建: userId={}, oldFamilyId={}, targetFamilyId={}, memberId={}",
+                    userId, oldFamilyId, targetFamilyId, member.getId());
         }
 
         // CF 值清零丢弃(留在原家庭)

+ 17 - 10
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -235,15 +235,17 @@ wrapper.in(Product::getId, productIds)
     }
 
     public Result<ProductDTO> create(Product product, Long vendorId) {
-        if (vendorId == null) {
-            return Result.error("请先登录");
-        }
-        User vendor = userMapper.selectById(vendorId);
-        if (vendor == null || !"approved".equals(vendor.getVendorStatus())) {
-            return Result.error("仅审核通过的服务商可发布商品");
+        if (vendorId != null) {
+            User vendor = userMapper.selectById(vendorId);
+            if (vendor == null || !"approved".equals(vendor.getVendorStatus())) {
+                return Result.error("仅审核通过的服务商可发布商品");
+            }
+            product.setVendorId(vendorId);
+            product.setVendorName(vendor.getNickname() != null ? vendor.getNickname() : vendor.getRealName());
+        } else {
+            product.setVendorId(null);
+            product.setVendorName(null);
         }
-        product.setVendorId(vendorId);
-        product.setVendorName(vendor.getNickname() != null ? vendor.getNickname() : vendor.getRealName());
         product.setStatus("pending");
         product.setCreatedAt(new Date());
         product.setUpdatedAt(new Date());
@@ -256,8 +258,13 @@ wrapper.in(Product::getId, productIds)
         if (existing == null) {
             return Result.error("商品不存在");
         }
-        if (!existing.getVendorId().equals(vendorId)) {
-            return Result.error("无权修改此商品");
+        if (vendorId != null) {
+            if (!existing.getVendorId().equals(vendorId)) {
+                return Result.error("无权修改此商品");
+            }
+        } else {
+            product.setVendorId(null);
+            product.setVendorName(null);
         }
         product.setStatus("pending");
         product.setUpdatedAt(new Date());

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskReminderConfigService.java

@@ -48,6 +48,8 @@ public class TaskReminderConfigService {
             dto.setTimingMode("after_timeout");
             dto.setOffsetMinutes(60);
             dto.setEnabled(1);
+            dto.setCompletionPraiseEnabled(0);
+            dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList("wechat", "in_app")));
         } else {
             dto.setChannels(entity.getChannels() == null || entity.getChannels().isEmpty()
                     ? new ArrayList<>(Arrays.asList("wechat"))
@@ -55,6 +57,13 @@ public class TaskReminderConfigService {
             dto.setTimingMode(entity.getTimingMode());
             dto.setOffsetMinutes(entity.getOffsetMinutes());
             dto.setEnabled(entity.getEnabled());
+            dto.setCompletionPraiseEnabled(entity.getCompletionPraiseEnabled() == null ? 0 : entity.getCompletionPraiseEnabled());
+            String pc = entity.getCompletionPraiseChannels();
+            if (pc == null || pc.isEmpty()) {
+                dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList("wechat", "in_app")));
+            } else {
+                dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList(pc.split(","))));
+            }
         }
         dto.setAvailableChannels(buildChannelStatus());
         return dto;
@@ -94,6 +103,21 @@ public class TaskReminderConfigService {
         entity.setTimingMode(timingMode);
         entity.setOffsetMinutes(offset);
         entity.setEnabled(dto.getEnabled() != null && dto.getEnabled() == 0 ? 0 : 1);
+        List<String> praiseChannels = dto.getCompletionPraiseChannels() == null ? new ArrayList<>() : dto.getCompletionPraiseChannels();
+        List<String> cleanedPraise = new ArrayList<>();
+        for (String ch : praiseChannels) {
+            if (ch == null) continue;
+            String c = ch.trim();
+            if (SUPPORTED_CHANNELS.contains(c) && !cleanedPraise.contains(c)) {
+                cleanedPraise.add(c);
+            }
+        }
+        if (cleanedPraise.isEmpty()) {
+            cleanedPraise.add("in_app");
+            cleanedPraise.add("wechat");
+        }
+        entity.setCompletionPraiseEnabled(dto.getCompletionPraiseEnabled() != null && dto.getCompletionPraiseEnabled() == 1 ? 1 : 0);
+        entity.setCompletionPraiseChannels(String.join(",", cleanedPraise));
         entity.setUpdatedAt(new Date());
         if (isNew) {
             taskReminderConfigMapper.insert(entity);

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
 import java.util.*;
 import com.etotem.cfc.util.DimensionCodes;
 import com.etotem.cfc.util.SortUtil;
+import com.etotem.cfc.service.NotificationDispatchService;
 
 @Slf4j
 @Service
@@ -30,6 +31,12 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private TaskMapper taskMapper;
 
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    @Resource
+    private TaskReminderConfigService taskReminderConfigService;
+
     @Resource
     private TaskExecutionMapper taskExecutionMapper;
 
@@ -640,6 +647,18 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         result.put("pointsEarned", pointsEarned);
         result.put("newBalance", child != null ? child.getTotalPoints() : 0);
         result.put("needReview", false);
+        try {
+            com.etotem.cfc.entity.TaskReminderConfig cfg = taskReminderConfigService.loadAllConfigs().get(task.getExecutorId());
+            if (cfg != null && Integer.valueOf(1).equals(cfg.getCompletionPraiseEnabled())) {
+                java.util.HashMap<String, Object> vars = new java.util.HashMap<String, Object>();
+                vars.put("title", task.getTitle());
+                String chans = cfg.getCompletionPraiseChannels();
+                if (chans == null || chans.isEmpty()) chans = "wechat,in_app";
+                notificationDispatchService.sendByTemplate("task_completion_praise", task.getExecutorId(), vars, "task", task.getId(),
+                        "任务完成,太棒了!",
+                        "太棒了!任务「" + task.getTitle() + "」已完成,继续保持,加油~");
+            }
+        } catch (Exception e) {}
         return result;
     }
 
@@ -814,6 +833,17 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         try { FamilyMember child = familyMemberMapper.selectById(task.getFamilyMemberId()); if (child != null) { this.completeOnboardingTask(child.getUserId(), "FIRST_TASK"); } } catch (Exception e) { /* onboarding trigger failure should not block task completion */ }
     result.put("newBalance", user.getTotalPoints());
     result.put("needReview", task.getNeedReview() == 1);
+    try {
+        Long receiver = userId;
+        com.etotem.cfc.entity.TaskReminderConfig cfg = taskReminderConfigService.loadAllConfigs().get(receiver);
+        if (cfg != null && Integer.valueOf(1).equals(cfg.getCompletionPraiseEnabled())) {
+            java.util.HashMap<String, Object> vars = new java.util.HashMap<String, Object>();
+            vars.put("title", task.getTitle());
+            notificationDispatchService.sendByTemplate("task_completion_praise", receiver, vars, "task", task.getId(),
+                    "任务完成,太棒了!",
+                    "太棒了!任务「" + task.getTitle() + "」已完成,继续保持,加油~");
+        }
+    } catch (Exception e) {}
 
     return result;
   }

+ 6 - 0
cfc-backend/src/main/resources/application.yml

@@ -103,6 +103,12 @@ wechat:
   promotion-tier-template-id: ${WECHAT_PROMOTION_TIER_TEMPLATE_ID:}
   # 小程序码环境:dev=development / test=trial / prod=release
   env-version: ${WECHAT_ENV_VERSION:release}
+  # 网站应用(微信开放平台“网站应用”)——桌面端扫码登录 SSO。
+  # 前置:该网站应用必须与小程序(appid=wx5ba8038ef16fb245)绑定同一开放平台账号,否则 unionid 不一致导致匹配失败。
+  web:
+    appid: ${WECHAT_WEB_APPID:}
+    secret: ${WECHAT_WEB_SECRET:}
+    redirect-uri: ${WECHAT_WEB_REDIRECT_URI:https://cfc.cf-club.com/api/auth/wechat-web/callback}
 
 dify:
   base-url: ${DIFY_BASE_URL:http://dify.bianwoyou.cn/v1}

+ 30 - 19
cfc-desktop-pet/README.md

@@ -1,31 +1,42 @@
-# 浠艾福桌面宠物(XafPet Desktop)
+# 浠艾福桌面宠物 (XafPet.Desktop)
 
-Windows + macOS 桌面宠物,展示五维能量 / 任务 / 打卡状态,点击宠物可完成喝水打卡。
+Windows 桌面宠物应用,实时反映 CFC 健康陪跑状态。
 
-## 平台与产物
+## 功能
 
-| 平台 | 架构 | 产物 | 签名 |
-|------|------|------|------|
-| Windows | x64 | NSIS/MSI | Authenticode(CI,可选) |
-| macOS | Intel + Apple Silicon(universal) | DMG | Developer ID + notarytool 公证 |
+- 显示今日任务进度 / 五维能量 / 健康打卡状态
+- 喝水提醒互动(点击宠物确认打卡)
+- 支持浠宝/福宝及自定义形象(PNG + pet-config.json)
+- 支持用户自定义状态(custom_state_1 ~ custom_state_10)
+- 托盘常驻,最小化到托盘,双击唤出
 
-**macOS 透明窗口依赖私有 API(`macOSPrivateApi`),不可上架 Mac App Store,仅支持公证 DMG 分发。**
+## 使用
+
+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
-cargo install tauri-cli
-cd src-tauri
-cargo tauri dev        # 开发(需 CFC 后端 localhost:9082)
-cargo test             # 单元测试
-cargo tauri build      # 双平台产物
-```
+# 构建
+dotnet build XafPet.Desktop.sln
 
-## 结构
+# 测试(需 Windows 机器,.NET 6 SDK)
+dotnet test tests/XafPet.Desktop.Tests -v
+```
 
-- `src-tauri/src/`:Rust 主进程(API / 状态映射 / 轮询 / 令牌 / 托盘)
-- `src/`:Web 渲染层(零构建静态文件,`bridge.js` 负责 Tauri 桥接)
+## 环境要求
 
-## 令牌
+- Windows 10 (1803+) / Windows 11
+- .NET 6 Runtime(发布时 Self-Contained,自带 WebView2 Runtime)
+- CFC 后端(localhost:9082)可选,不连接后端时宠物保持 idle 状态
 
-存储于系统安全存储:Windows Credential Manager / macOS Keychain(服务 `xafpet`)。从旧 WPF 版(DPAPI)升级需重新输入一次令牌。
+---
+> **注意:** 本项目仅在 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

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

@@ -135,6 +135,16 @@ impl ApiClient {
         let text = resp.text().await.map_err(|e| ApiError::Http(e.to_string()))?;
         self.parse_response(&text)
     }
+
+    /// 微信网站应用扫码登录:获取二维码链接
+    pub async fn wechat_web_qr(&self) -> Result<WechatQrResp, ApiError> {
+        self.post("/api/auth/wechat-web/qr", Some(&serde_json::json!({}))).await
+    }
+
+    /// 轮询扫码登录结果(state 由 wechat_web_qr 返回)
+    pub async fn wechat_web_poll(&self, state: &str) -> Result<WechatPollResp, ApiError> {
+        self.post("/api/auth/wechat-web/poll", Some(&serde_json::json!({ "state": state }))).await
+    }
 }
 
 #[cfg(test)]

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

@@ -110,6 +110,28 @@ pub struct MediaRecord {
     pub url: Option<String>,
 }
 
+/// POST /api/auth/wechat-web/qr → {state, url}
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct WechatQrResp {
+    pub state: String,
+    pub url: String,
+}
+
+/// POST /api/auth/wechat-web/poll → {status, token?, userId?, role?, nickname?, message?}
+/// status: pending(等待扫码) / success / notfound(微信未绑定小程序账号) / expired / error
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct WechatPollResp {
+    pub status: String,
+    pub token: Option<String>,
+    pub user_id: Option<i64>,
+    pub role: Option<String>,
+    pub nickname: Option<String>,
+    #[allow(dead_code)]
+    pub message: Option<String>,
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -184,4 +206,32 @@ mod tests {
         assert!(r.is_success());
         assert!(r.data.unwrap().mascot.is_none());
     }
+
+    #[test]
+    fn parses_wechat_qr_response() {
+        let json = r#"{"code":200,"message":"success","data":{"state":"abc123","url":"https://open.weixin.qq.com/connect/qrconnect?appid=x"}}"#;
+        let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
+        assert!(r.is_success());
+        let q = r.data.unwrap();
+        assert_eq!(q.state, "abc123");
+        assert!(q.url.contains("qrconnect"));
+    }
+
+    #[test]
+    fn parses_wechat_poll_success_and_pending() {
+        let ok: ApiResult<WechatPollResp> = serde_json::from_str(
+            r#"{"code":200,"data":{"status":"success","token":"jwt","userId":7,"role":"parent","nickname":"牛"}}"#,
+        )
+        .unwrap();
+        let p = ok.data.unwrap();
+        assert_eq!(p.status, "success");
+        assert_eq!(p.token.as_deref(), Some("jwt"));
+        assert_eq!(p.user_id, Some(7));
+
+        let pending: ApiResult<WechatPollResp> =
+            serde_json::from_str(r#"{"code":200,"data":{"status":"pending"}}"#).unwrap();
+        let p2 = pending.data.unwrap();
+        assert_eq!(p2.status, "pending");
+        assert!(p2.token.is_none());
+    }
 }

+ 93 - 0
cfc-desktop-pet/src-tauri/src/commands.rs

@@ -112,6 +112,99 @@ pub fn clear_custom_mascot(app: AppHandle, settings: State<SettingsStore>) -> Re
     Ok(())
 }
 
+/// 微信扫码登录:取二维码链接 → 用系统浏览器打开 → 后台轮询换取 JWT → 写入 keyring。
+/// 结果通过 `pet://wechat-login` 事件回传
+/// (status: pending / success / notfound / expired / error / timeout)。
+#[tauri::command]
+pub async fn start_wechat_login(app: AppHandle) -> Result<serde_json::Value, String> {
+    let qr = {
+        let state = app.state::<AppState>();
+        state
+            .api
+            .wechat_web_qr()
+            .await
+            .map_err(|e| format!("获取二维码失败: {e}"))?
+    };
+    open_url(&qr.url)?;
+
+    let api = app.state::<AppState>().api.clone();
+    let state_token = qr.state.clone();
+    let handle = app.clone();
+    tauri::async_runtime::spawn(async move {
+        // 最多轮询约 3 分钟(2s 间隔 × 90 次)
+        for _ in 0..90 {
+            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
+            match api.wechat_web_poll(&state_token).await {
+                Ok(resp) => match resp.status.as_str() {
+                    "pending" => continue,
+                    "success" => {
+                        let Some(token) = resp.token else {
+                            let _ = handle.emit(
+                                "pet://wechat-login",
+                                serde_json::json!({"status": "error", "message": "响应缺少 token"}),
+                            );
+                            return;
+                        };
+                        if let Err(e) = TokenStore::new().save(&token) {
+                            let _ = handle.emit(
+                                "pet://wechat-login",
+                                serde_json::json!({"status": "error", "message": format!("保存令牌失败: {e}")}),
+                            );
+                            return;
+                        }
+                        api.update_token(token);
+                        if let Some(s) = handle.try_state::<AppState>() {
+                            restart_polling(&handle, &s);
+                        }
+                        let _ = handle.emit(
+                            "pet://wechat-login",
+                            serde_json::json!({"status": "success", "nickname": resp.nickname}),
+                        );
+                        return;
+                    }
+                    other => {
+                        let _ = handle.emit(
+                            "pet://wechat-login",
+                            serde_json::json!({"status": other, "message": resp.message}),
+                        );
+                        return;
+                    }
+                },
+                Err(_) => continue, // 瞬时网络错误:继续轮询
+            }
+        }
+        let _ = handle.emit("pet://wechat-login", serde_json::json!({"status": "timeout"}));
+    });
+
+    Ok(serde_json::json!({ "status": "pending", "url": qr.url }))
+}
+
+/// 用系统默认浏览器打开 URL(Windows: cmd start / macOS: open / 其它: xdg-open)。
+fn open_url(url: &str) -> Result<(), String> {
+    #[cfg(target_os = "windows")]
+    {
+        std::process::Command::new("cmd")
+            .args(["/C", "start", "", url])
+            .spawn()
+            .map_err(|e| e.to_string())?;
+    }
+    #[cfg(target_os = "macos")]
+    {
+        std::process::Command::new("open")
+            .arg(url)
+            .spawn()
+            .map_err(|e| e.to_string())?;
+    }
+    #[cfg(not(any(target_os = "windows", target_os = "macos")))]
+    {
+        std::process::Command::new("xdg-open")
+            .arg(url)
+            .spawn()
+            .map_err(|e| e.to_string())?;
+    }
+    Ok(())
+}
+
 #[tauri::command]
 pub fn save_settings(
     app: AppHandle,

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

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

+ 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>

+ 34 - 0
cfc-desktop-pet/src/settings.html

@@ -35,6 +35,12 @@
   <button id="upload-mascot" disabled>上传并使用此形象</button>
   <button id="clear-mascot" class="secondary">恢复默认形象</button>
 
+  <hr />
+
+  <label>微信登录</label>
+  <button id="wechat-login">微信扫码登录</button>
+  <div class="hint">用微信扫码,自动与小程序账号同步登录(无需手动填令牌)</div>
+
   <div id="status"></div>
   <script>
     (function () {
@@ -122,6 +128,34 @@
           setTimeout(function () { setStatus(''); }, 2500);
         }).catch(function (e) { setStatus('操作失败:' + e); });
       });
+
+      document.getElementById('wechat-login').addEventListener('click', function () {
+        setStatus('正在打开微信登录…');
+        invoke('start_wechat_login').then(function () {
+          setStatus('请用微信扫码,完成后自动登录');
+        }).catch(function (e) { setStatus('发起登录失败:' + e); });
+      });
+
+      // 后台轮询任务完成后由 Rust 侧 emit
+      try {
+        window.__TAURI__.event.listen('pet://wechat-login', function (ev) {
+          var p = (ev && ev.payload) || {};
+          if (p.status === 'success') {
+            setStatus('登录成功' + (p.nickname ? ':' + p.nickname : ''));
+            tokenEl.value = '';
+          } else if (p.status === 'pending') {
+            setStatus('等待扫码…');
+          } else if (p.status === 'timeout') {
+            setStatus('登录超时,请重试');
+          } else if (p.status === 'notfound') {
+            setStatus('该微信未绑定小程序账号');
+          } else if (p.status === 'expired') {
+            setStatus('二维码已过期,请重试');
+          } else {
+            setStatus('登录失败:' + (p.message || p.status));
+          }
+        });
+      } catch (e) { /* event API 不可用时忽略 */ }
     })();
   </script>
 </body>

+ 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>

+ 301 - 152
cfc-frontend/components/address-picker.vue

@@ -1,38 +1,66 @@
 <template>
   <view class="address-picker">
-    <view class="picker-row" @click="showProvincePicker">
-      <text class="picker-label">省份:</text>
-      <text class="picker-value">{{ selectedProvince ? selectedProvince.province : '请选择' }}</text>
-      <text class="picker-arrow">▶</text>
+    <!-- 显示当前选中的地址 -->
+    <view class="current-address" @click="openPicker">
+      <text class="address-text">{{ currentAddress || '请选择地址' }}</text>
+      <text class="address-arrow">›</text>
     </view>
-    
-    <view class="picker-row" @click="showCityPicker" v-if="selectedProvince">
-      <text class="picker-label">城市:</text>
-      <text class="picker-value">{{ selectedCity ? selectedCity.city : '请选择' }}</text>
-      <text class="picker-arrow">▶</text>
-    </view>
-    
-    <view class="picker-row" @click="showDistrictPicker" v-if="selectedCity">
-      <text class="picker-label">区县:</text>
-      <text class="picker-value">{{ selectedDistrict ? selectedDistrict.district : '请选择' }}</text>
-      <text class="picker-arrow">▶</text>
-    </view>
-    
-    <view class="picker-row" @click="showStreetPicker" v-if="selectedDistrict">
-      <text class="picker-label">街道:</text>
-      <text class="picker-value">{{ selectedStreet ? selectedStreet.street : '请选择' }}</text>
-      <text class="picker-arrow">▶</text>
-    </view>
-    
-    <view class="address-display" v-if="fullAddress">
-      <text class="address-label">完整地址:</text>
-      <text class="address-text">{{ fullAddress }}</text>
+
+    <!-- 地址选择面板 -->
+    <view class="picker-mask" v-if="showPicker" @click="closePicker">
+      <view class="picker-panel" @click.stop>
+        <!-- 面板头部 -->
+        <view class="picker-header">
+          <text class="picker-btn" @click="closePicker">取消</text>
+          <text class="picker-title">选择地址</text>
+          <text class="picker-btn picker-confirm" @click="confirmAddress">确定</text>
+        </view>
+
+        <!-- 面包屑导航 -->
+        <view class="picker-breadcrumb">
+          <text
+            :class="['crumb', currentLevel === 0 ? 'crumb-active' : '']"
+            @click="jumpToLevel(0)"
+          >{{ tempProvince || '省份' }}</text>
+          <text v-if="tempProvince" class="crumb-arrow">›</text>
+          <text
+            :class="['crumb', currentLevel === 1 ? 'crumb-active' : '']"
+            @click="jumpToLevel(1)"
+          >{{ tempCity || '城市' }}</text>
+          <text v-if="tempCity" class="crumb-arrow">›</text>
+          <text
+            :class="['crumb', currentLevel === 2 ? 'crumb-active' : '']"
+            @click="jumpToLevel(2)"
+          >{{ tempDistrict || '区县' }}</text>
+          <text v-if="tempDistrict" class="crumb-arrow">›</text>
+          <text
+            :class="['crumb', currentLevel === 3 ? 'crumb-active' : '']"
+            @click="jumpToLevel(3)"
+          >{{ tempStreet || '街道' }}</text>
+        </view>
+
+        <!-- 选择列表 -->
+        <scroll-view class="picker-list" scroll-y>
+          <view
+            v-for="(item, idx) in currentItems"
+            :key="idx"
+            :class="['picker-item', isSelected(item) ? 'picker-item-selected' : '']"
+            @click="selectItem(item)"
+          >
+            <text class="item-text">{{ getItemName(item) }}</text>
+            <text v-if="isSelected(item)" class="item-check">✓</text>
+          </view>
+          <view v-if="loading" class="picker-loading">
+            <text>加载中...</text>
+          </view>
+        </scroll-view>
+      </view>
     </view>
   </view>
 </template>
 
 <script>
-import { getProvinces, getStreetChildren, getStreetPath } from '@/utils/api.js'
+import { getProvinces, getStreetChildren } from '@/utils/api.js'
 
 export default {
   name: 'AddressPicker',
@@ -44,22 +72,30 @@ export default {
   },
   data() {
     return {
+      showPicker: false,
+      currentLevel: 0, // 0=省份, 1=城市, 2=区县, 3=街道
+      loading: false,
+      // 数据列表
       provinces: [],
       cities: [],
       districts: [],
       streets: [],
-      selectedProvince: null,
-      selectedCity: null,
-      selectedDistrict: null,
-      selectedStreet: null
+      // 临时选中项
+      tempProvince: null,
+      tempCity: null,
+      tempDistrict: null,
+      tempStreet: null,
+      // 完整地址文本
+      currentAddress: ''
     }
   },
   computed: {
-    fullAddress() {
-      if (this.selectedStreet) {
-        return this.selectedStreet.fullName
-      }
-      return ''
+    currentItems() {
+      if (this.currentLevel === 0) return this.provinces
+      if (this.currentLevel === 1) return this.cities
+      if (this.currentLevel === 2) return this.districts
+      if (this.currentLevel === 3) return this.streets
+      return []
     }
   },
   watch: {
@@ -84,7 +120,7 @@ export default {
         console.error('加载省份失败', e)
       }
     },
-    
+
     async loadCities(provinceId) {
       try {
         const res = await getStreetChildren(provinceId)
@@ -93,7 +129,7 @@ export default {
         console.error('加载城市失败', e)
       }
     },
-    
+
     async loadDistricts(cityId) {
       try {
         const res = await getStreetChildren(cityId)
@@ -102,7 +138,7 @@ export default {
         console.error('加载区县失败', e)
       }
     },
-    
+
     async loadStreets(districtId) {
       try {
         const res = await getStreetChildren(districtId)
@@ -111,122 +147,142 @@ export default {
         console.error('加载街道失败', e)
       }
     },
-    
+
     async loadAddressById(streetId) {
       try {
-        const res = await getStreetPath(streetId)
+        const res = await getStreetChildren(streetId)
         const path = res.data || []
-        
-        if (path.length >= 1) this.selectedProvince = path[0]
-        if (path.length >= 2) this.selectedCity = path[1]
-        if (path.length >= 3) this.selectedDistrict = path[2]
-        if (path.length >= 4) this.selectedStreet = path[3]
-        
+
+        if (path.length >= 4) {
+          this.tempProvince = path[0]
+          this.tempCity = path[1]
+          this.tempDistrict = path[2]
+          this.tempStreet = path[3]
+          this.currentAddress = path[3].fullName || this.buildAddress(path)
+        }
+
         // 加载下级选项
-        if (this.selectedProvince) await this.loadCities(this.selectedProvince.id)
-        if (this.selectedCity) await this.loadDistricts(this.selectedCity.id)
-        if (this.selectedDistrict) await this.loadStreets(this.selectedDistrict.id)
+        if (this.tempProvince) await this.loadCities(this.tempProvince.id)
+        if (this.tempCity) await this.loadDistricts(this.tempCity.id)
+        if (this.tempDistrict) await this.loadStreets(this.tempDistrict.id)
       } catch (e) {
         console.error('加载地址路径失败', e)
       }
     },
-    
-    showProvincePicker() {
-      uni.showActionSheet({
-        itemList: this.provinces.map(p => p.province),
-        success: async (res) => {
-          this.selectedProvince = this.provinces[res.tapIndex]
-          this.selectedCity = null
-          this.selectedDistrict = null
-          this.selectedStreet = null
-          this.cities = []
-          this.districts = []
-          this.streets = []
-          
-          await this.loadCities(this.selectedProvince.id)
-          this.emitChange()
-        }
-      })
+
+    buildAddress(path) {
+      if (!path || path.length === 0) return ''
+      var parts = []
+      if (path[0]) parts.push(path[0].province || path[0].name)
+      if (path[1]) parts.push(path[1].city || path[1].name)
+      if (path[2]) parts.push(path[2].district || path[2].name)
+      if (path[3]) parts.push(path[3].street || path[3].name)
+      return parts.join('')
     },
-    
-    showCityPicker() {
-      if (this.cities.length === 0) {
-        uni.showToast({ title: '请先选择省份', icon: 'none' })
-        return
+
+    openPicker() {
+      this.showPicker = true
+      if (!this.tempProvince && this.provinces.length === 0) {
+        this.loadProvinces()
       }
-      
-      uni.showActionSheet({
-        itemList: this.cities.map(c => c.city),
-        success: async (res) => {
-          this.selectedCity = this.cities[res.tapIndex]
-          this.selectedDistrict = null
-          this.selectedStreet = null
-          this.districts = []
-          this.streets = []
-          
-          await this.loadDistricts(this.selectedCity.id)
-          this.emitChange()
-        }
-      })
     },
-    
-    showDistrictPicker() {
-      if (this.districts.length === 0) {
-        uni.showToast({ title: '请先选择城市', icon: 'none' })
-        return
+
+    closePicker() {
+      this.showPicker = false
+    },
+
+    jumpToLevel(level) {
+      if (level < this.currentLevel) {
+        this.currentLevel = level
       }
-      
-      uni.showActionSheet({
-        itemList: this.districts.map(d => d.district),
-        success: async (res) => {
-          this.selectedDistrict = this.districts[res.tapIndex]
-          this.selectedStreet = null
-          this.streets = []
-          
-          await this.loadStreets(this.selectedDistrict.id)
-          this.emitChange()
-        }
-      })
     },
-    
-    showStreetPicker() {
-      if (this.streets.length === 0) {
-        uni.showToast({ title: '请先选择区县', icon: 'none' })
-        return
+
+    selectItem(item) {
+      if (this.currentLevel === 0) {
+        this.tempProvince = item
+        this.tempCity = null
+        this.tempDistrict = null
+        this.tempStreet = null
+        this.cities = []
+        this.districts = []
+        this.streets = []
+        this.currentLevel = 1
+        this.loadCities(item.id)
+      } else if (this.currentLevel === 1) {
+        this.tempCity = item
+        this.tempDistrict = null
+        this.tempStreet = null
+        this.districts = []
+        this.streets = []
+        this.currentLevel = 2
+        this.loadDistricts(item.id)
+      } else if (this.currentLevel === 2) {
+        this.tempDistrict = item
+        this.tempStreet = null
+        this.streets = []
+        this.currentLevel = 3
+        this.loadStreets(item.id)
+      } else if (this.currentLevel === 3) {
+        this.tempStreet = item
+        this.confirmAddress()
       }
-      
-      uni.showActionSheet({
-        itemList: this.streets.map(s => s.street),
-        success: (res) => {
-          this.selectedStreet = this.streets[res.tapIndex]
-          this.emitChange()
-        }
-      })
     },
-    
+
+    confirmAddress() {
+      this.updateAddressText()
+      this.emitChange()
+      this.closePicker()
+    },
+
+    updateAddressText() {
+      var parts = []
+      if (this.tempProvince) parts.push(this.tempProvince.province || this.tempProvince.name)
+      if (this.tempCity) parts.push(this.tempCity.city || this.tempCity.name)
+      if (this.tempDistrict) parts.push(this.tempDistrict.district || this.tempDistrict.name)
+      if (this.tempStreet) parts.push(this.tempStreet.street || this.tempStreet.name)
+      this.currentAddress = parts.join('')
+    },
+
     emitChange() {
-      const streetId = this.selectedStreet ? this.selectedStreet.id : null
+      const streetId = this.tempStreet ? this.tempStreet.id : null
       const addressData = {
         streetId: streetId,
-        province: this.selectedProvince ? this.selectedProvince.province : null,
-        city: this.selectedCity ? this.selectedCity.city : null,
-        district: this.selectedDistrict ? this.selectedDistrict.district : null,
-        street: this.selectedStreet ? this.selectedStreet.street : null,
-        fullName: this.fullAddress
+        province: this.tempProvince ? this.tempProvince.province : null,
+        city: this.tempCity ? this.tempCity.city : null,
+        district: this.tempDistrict ? this.tempDistrict.district : null,
+        street: this.tempStreet ? this.tempStreet.street : null,
+        fullName: this.currentAddress
       }
-      
+
       this.$emit('input', streetId)
       this.$emit('change', addressData)
     },
-    
+
+    getItemName(item) {
+      if (!item) return ''
+      return item.province || item.city || item.district || item.street || item.name || ''
+    },
+
+    isSelected(item) {
+      var name = this.getItemName(item)
+      if (this.currentLevel === 0) return this.tempProvince && this.getItemName(this.tempProvince) === name
+      if (this.currentLevel === 1) return this.tempCity && this.getItemName(this.tempCity) === name
+      if (this.currentLevel === 2) return this.tempDistrict && this.getItemName(this.tempDistrict) === name
+      if (this.currentLevel === 3) return this.tempStreet && this.getItemName(this.tempStreet) === name
+      return false
+    },
+
     reset() {
-      this.selectedProvince = null
-      this.selectedCity = null
-      this.selectedDistrict = null
-      this.selectedStreet = null
+      this.tempProvince = null
+      this.tempCity = null
+      this.tempDistrict = null
+      this.tempStreet = null
+      this.currentAddress = ''
+      this.provinces = []
       this.cities = []
       this.districts = []
       this.streets = []
+      this.currentLevel = 0
       this.emitChange()
     }
   }
@@ -235,51 +291,144 @@ export default {
 
 <style scoped>
 .address-picker {
+  width: 100%;
+}
+
+.current-address {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 24rpx 20rpx;
+  background: #f8f8f8;
+  border-radius: 12rpx;
+  border: 2rpx solid #eee;
+}
+
+.address-text {
+  font-size: 28rpx;
+  color: #333;
+  flex: 1;
+}
+
+.address-arrow {
+  font-size: 32rpx;
+  color: #999;
+  margin-left: 10rpx;
+}
+
+/* 面板遮罩 */
+.picker-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+}
+
+.picker-panel {
+  width: 100%;
   background: #fff;
-  padding: 20rpx;
+  border-radius: 24rpx 24rpx 0 0;
+  max-height: 70vh;
+  display: flex;
+  flex-direction: column;
 }
 
-.picker-row {
+/* 面板头部 */
+.picker-header {
   display: flex;
   align-items: center;
-  padding: 20rpx 0;
+  justify-content: space-between;
+  padding: 30rpx 30rpx 20rpx;
   border-bottom: 1px solid #eee;
 }
 
-.picker-label {
+.picker-btn {
   font-size: 28rpx;
   color: #666;
-  width: 140rpx;
+  padding: 10rpx 20rpx;
 }
 
-.picker-value {
-  flex: 1;
-  font-size: 28rpx;
+.picker-confirm {
+  color: #f97316;
+  font-weight: bold;
+}
+
+.picker-title {
+  font-size: 32rpx;
+  font-weight: bold;
   color: #333;
 }
 
-.picker-arrow {
-  font-size: 24rpx;
-  color: #999;
-  margin-left: 10rpx;
+/* 面包屑导航 */
+.picker-breadcrumb {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 30rpx;
+  background: #fafafa;
+  border-bottom: 1px solid #eee;
+  flex-wrap: wrap;
 }
 
-.address-display {
-  margin-top: 20rpx;
-  padding: 20rpx;
-  background: #f5f5f5;
-  border-radius: 8rpx;
+.crumb {
+  font-size: 26rpx;
+  color: #666;
+  padding: 8rpx 16rpx;
+  background: #eee;
+  border-radius: 20rpx;
 }
 
-.address-label {
-  font-size: 24rpx;
-  color: #999;
-  display: block;
-  margin-bottom: 10rpx;
+.crumb-active {
+  background: #f97316;
+  color: #fff;
 }
 
-.address-text {
+.crumb-arrow {
   font-size: 28rpx;
+  color: #999;
+  margin: 0 8rpx;
+}
+
+/* 选择列表 */
+.picker-list {
+  flex: 1;
+  min-height: 300rpx;
+  max-height: 500rpx;
+  padding: 20rpx 0;
+}
+
+.picker-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 28rpx 40rpx;
+  border-bottom: 1px solid #f5f5f5;
+}
+
+.item-text {
+  font-size: 30rpx;
   color: #333;
 }
+
+.item-check {
+  font-size: 32rpx;
+  color: #f97316;
+  font-weight: bold;
+}
+
+.picker-item-selected .item-text {
+  color: #f97316;
+  font-weight: bold;
+}
+
+.picker-loading {
+  padding: 40rpx;
+  text-align: center;
+  color: #999;
+  font-size: 28rpx;
+}
 </style>

+ 156 - 168
cfc-frontend/pages/index-home/index.vue

@@ -1,7 +1,5 @@
 <template>
 <view>
-  <!-- 通用登录浮层 -->
-  <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
   <!-- 冷启动 splash overlay — 覆盖首页直到数据加载完毕(tab 切回时不显示) -->
   <view class="splash-overlay" :class="{'fade-out': splashOverlayFading}" v-if="splashOverlay || splashOverlayFading">
     <view class="splash-overlay-bg">
@@ -101,10 +99,17 @@
     <!-- ② 发现区 — 上传报告 -->
 
     <FamilyFeed
-      :items="feedItems"
+      :items="familyFeed"
       :notices="notices"
-      :showHeader="true"
-      @viewed="onFeedItemViewed" />
+      :showHeader="true" />
+
+    <!-- 能量/积分/CF值变更通知 - 从下滑动展示 -->
+    <view v-if="showEnergyBadge || showPointsBadge || showCfBadge" class="change-notification-bar" :class="{ 'visible': isNotificationVisible }" @click="handleNotificationClick">
+      <view v-if="showEnergyBadge" class="change-item energy" @click.stop="onEnergyClick">{{ formatEnergyNotification }}</view>
+      <view v-if="showPointsBadge" class="change-item points" @click.stop="onPointsClick">{{ formatPointsNotification }}</view>
+      <view v-if="showCfBadge" class="change-item cf" @click.stop="onCfClick">{{ formatCfNotification }}</view>
+      <view class="change-dismiss" @click="dismissAll">✕</view>
+    </view>
 
     <!-- ④ 内容区 — 推荐文章 -->
 
@@ -425,7 +430,7 @@ import ReportUploadCard from '../../components/report-upload-card.vue'
 import OnboardingProgressBar from '../../components/OnboardingProgressBar.vue'
 import OnboardingGuide from '../../components/OnboardingGuide.vue'
 import ConfigurableGuide from '../../components/ConfigurableGuide.vue'
-import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, getGuideConfig, getGuidePopupList, consumeGuidePopup, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, readNotification, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode, getAppLibrary, reportAppEnter, getEnergyLogs, getPointsLogs, getCfList, getCfSummary } from '../../utils/api.js'
+import { acceptParentInvite, getActivityList, getFeaturedArticles, getEnergySandbox, getEnergyOverview, getFamilyMemberList, getChallengeList, getGuideConfig, getGuidePopupList, consumeGuidePopup, switchToFamilyMember, getMyMembership, updateChallengeProgress, respondChallenge, getTodayTasks, completeTask as completeTaskApi, getTodayParentTasks, completeParentTask as completeParentTaskApi, getTaskHistory, getNotices, getNotificationList, getSurveyStatus, getSelfCheckStatus, getReferralCode, extractReferralCode, getAppLibrary, reportAppEnter, getEnergyLogs, getPointsLogs, getCfList, getCfSummary } from '../../utils/api.js'
 import { findAppByKey, getShortcutableApps } from '../../utils/app-library.js'
 import nav from '../../utils/nav.js'
 import config from '@/config.js'
@@ -457,9 +462,6 @@ components: {
     return {
       currentRole: _currentRole,
 
-      // 已查看的动态提醒键(点击后从动态中移除;持久化,重启/刷新后不再出现)
-      viewedFeedKeys: (uni.getStorageSync('viewedFeedKeys') || []).slice(0, 200),
-
       // splash loading overlay(冷启动时展示,数据加载完关闭)
       splashOverlay: _isFirstLaunch,
       splashOverlayFading: false,
@@ -480,7 +482,7 @@ components: {
       gradientColors: [
         'linear-gradient(135deg, #10B981, #34D399)',
         'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
-        'linear-gradient(135deg, #F59E0B, #FBBF24)',
+        'linear-gradient(135deg, #8B5CF6, #C084FC)',
         'linear-gradient(135deg, #F97316, #FB923C)',
         'linear-gradient(135deg, #6366F1, #818CF8)'
       ],
@@ -525,10 +527,15 @@ membershipDays: 0,
       appUsage: {},
       quickLoaded: false,
 
-      // 能量/积分/CF值变更记录(合并展示于首页动态提醒,带具体值)
+      // 能量/积分/CF值变更通知
       energyMessages: [],
       pointsMessages: [],
-      cfMessages: []
+      cfMessages: [],
+      showEnergyBadge: false,
+      showPointsBadge: false,
+      showCfBadge: false,
+      // 通知栏可见性(从下往上滑入)
+      isNotificationVisible: false
 
 
     }
@@ -686,100 +693,36 @@ title: '添加家庭成员',
         color: 'gray'
       }
     },
-    // ===== 首页动态提醒列表(含具体值)=====
-    // 顺序:今日能量/积分/CF 变更(带数值)→ 家庭动态(通知/公告/任务完成)
-    feedItems: function() {
-      var list = []
-      // 1) 今日能量/积分/CF 变更(每条携带获取到的具体数值)
-      var groups = [
-        { arr: this.energyMessages, icon: '⚡', unit: '能量' },
-        { arr: this.pointsMessages, icon: '🪙', unit: '积分' },
-        { arr: this.cfMessages, icon: '💰', unit: 'CF' }
-      ]
-      for (var g = 0; g < groups.length; g++) {
-        var grp = groups[g]
-        var arr = grp.arr || []
-        for (var i = 0; i < arr.length; i++) {
-          var m = arr[i] || {}
-          var amount = m.amount || 0
-          var amountStr = (amount > 0 ? '+' : '') + amount + grp.unit
-          var desc = m.description || m.title || (grp.unit + '变动')
-          // 能量描述不带数值 → 末位拼接;积分/CF 描述本身已含数值,避免重复
-          var lineDesc = desc
-          if (grp.unit === '能量' && amount) {
-            lineDesc = desc + ' ' + amountStr
-          }
-          list.push({
-            text: grp.icon + ' ' + lineDesc,
-            time: m.time || '',
-            type: m.type || 'change',
-            title: m.title || (grp.unit + '变动'),
-            content: lineDesc,
-            amount: '',
-            unread: false,
-            _feedKey: m.id || ''
-          })
-        }
+    // ===== 能量/积分/CF值变更通知格式化(标题带具体数量) =====
+    formatEnergyNotification: function() {
+      var total = 0
+      var arr = this.energyMessages || []
+      for (var i = 0; i < arr.length; i++) {
+        total += (arr[i].amount || 0)
       }
-      // 2) 家庭动态(公告/通知/任务完成,任务完成已带 +N 分具体值)
-      var feed = this.familyFeed || []
-      for (var j = 0; j < feed.length; j++) {
-        var f = feed[j]
-        if (!f) continue
-        if (f.type === 'task') {
-          list.push({
-            text: f.text || '',
-            time: f.time || '',
-            type: 'task',
-            title: (f.who || '成员') + ' 完成「' + (f.taskTitle || '任务') + '」',
-            content: '',
-            amount: f.pointsText || '+' + (f.points || 0) + '分',
-            unread: false,
-            _feedKey: f.id || ''
-          })
-        } else if (f.type === 'notification') {
-          var nn = f.notice || {}
-          list.push({
-            text: f.text || '',
-            time: f.time || '',
-            type: 'notification',
-            title: nn.title || f.text || '通知',
-            content: nn.content || '',
-            amount: '',
-            unread: !!f.unread,
-            _feedKey: 'notification-' + (nn.id !== undefined ? nn.id : ''),
-            _srcId: nn.id || ''
-          })
-        } else if (f.type === 'notice') {
-          var nb = f.notice || {}
-          list.push({
-            text: f.text || '',
-            time: f.time || '',
-            type: 'notice',
-            title: nb.title || f.text || '公告',
-            content: nb.content || '',
-            amount: '',
-            unread: false,
-            _feedKey: 'notice-' + (nb.id !== undefined ? nb.id : '')
-          })
-        } else {
-          list.push({
-            text: f.text || '',
-            time: f.time || '',
-            type: f.type || 'feed',
-            title: f.text || '',
-            content: '',
-            amount: '',
-            unread: false,
-            _feedKey: f._feedKey || ''
-          })
-        }
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : '能量变动'
+      return '⚡ 能量 ' + sign + total + ' · ' + first
+    },
+    formatPointsNotification: function() {
+      var total = 0
+      var arr = this.pointsMessages || []
+      for (var i = 0; i < arr.length; i++) {
+        total += (arr[i].amount || 0)
+      }
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : '积分变动'
+      return '🪙 积分 ' + sign + total + ' · ' + first
+    },
+    formatCfNotification: function() {
+      var total = 0
+      var arr = this.cfMessages || []
+      for (var i = 0; i < arr.length; i++) {
+        total += (arr[i].amount || 0)
       }
-      // 已查看过的动态不再展示(点击后从提醒中移除),只保留前 20 条
-      var viewed = this.viewedFeedKeys || []
-      return list.filter(function(item) {
-        return !item._feedKey || viewed.indexOf(item._feedKey) === -1
-      }).slice(0, 20)
+      var sign = total > 0 ? '+' : ''
+      var first = arr.length > 0 ? arr[0].description : 'CF值变动'
+      return '💰 CF值 ' + sign + total + ' · ' + first
     }
   },
   onLoad(options) {
@@ -1051,7 +994,7 @@ title: '添加家庭成员',
           var gradientColors = [
             'linear-gradient(135deg, #10B981, #34D399)',
             'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
-            'linear-gradient(135deg, #F59E0B, #FBBF24)',
+            'linear-gradient(135deg, #8B5CF6, #C084FC)',
             'linear-gradient(135deg, #F97316, #FB923C)',
             'linear-gradient(135deg, #6366F1, #818CF8)'
           ]
@@ -1136,15 +1079,9 @@ title: '添加家庭成员',
             timeStr = t.completedAt.substring(11, 16)
           }
           return {
-            id: 'task-' + (t.id !== undefined ? t.id : (t.taskId !== undefined ? t.taskId : '')),
             text: '✅ ' + (child.nickname || '小名') + ' 完成「' + (t.title || '任务') + '」 +' + points + '分',
             time: timeStr,
-            type: 'task',
-            // 补充详情字段(首页动态提醒点击弹窗使用)
-            who: child.nickname || '小名',
-            taskTitle: t.title || '任务',
-            points: points,
-            pointsText: '+' + points + '分'
+            type: 'task'
           }
         })
         self.familyFeed = feed.slice(0, 10)
@@ -1171,19 +1108,13 @@ title: '添加家庭成员',
               notice: n
             }
           })
-          // 已查看过的公告不再进入动态(历史消息页仍可查)
-          var viewed = self.viewedFeedKeys || []
-          self.notices = notices.filter(function(item) {
-            var raw = item.notice || {}
-            var key = 'notice-' + (raw.id !== undefined ? raw.id : '')
-            return viewed.indexOf(key) === -1
-          })
+          self.notices = notices
           // 合并 notices 到 familyFeed(优先显示)
-          self.familyFeed = self.notices.concat(self.familyFeed).slice(0, 10)
+          self.familyFeed = notices.concat(self.familyFeed).slice(0, 10)
         }
       }).catch(function() {})
     },
-    // W3: 用户通知入动态(未读优先显示在 familyFeed 顶部)
+    // W3: 用户通知入动态(仅显示未读,已读不计入 familyFeed 顶部)
     loadNotifications: function() {
       var self = this
       getNotificationList().then(function(res) {
@@ -1204,39 +1135,12 @@ title: '添加家庭成员',
               notice: n
             }
           })
-          // 只将「未读」通知合并到动态(已读通知归入历史消息页,不再回到动态)
+          // 仅显示未读通知在 familyFeed 顶部(最多10条)
           var unread = notifications.filter(function(n) { return n.unread })
-          self.familyFeed = unread.concat(self.familyFeed).slice(0, 10)
+          self.familyFeed = unread.slice(0, 10)
         }
       }).catch(function() {})
     },
-    // 动态提醒点击查看后:从动态中移除 + 系统通知同步标记已读(历史消息页仍可查)
-    onFeedItemViewed: function(item) {
-      if (!item || !item._feedKey) return
-      var key = item._feedKey
-      if (this.viewedFeedKeys.indexOf(key) === -1) {
-        this.viewedFeedKeys.push(key)
-        if (this.viewedFeedKeys.length > 200) {
-          this.viewedFeedKeys = this.viewedFeedKeys.slice(-200)
-        }
-        uni.setStorageSync('viewedFeedKeys', this.viewedFeedKeys)
-      }
-      // 公告同时从 notices 数组中移除(组件会经 notices prop 展示)
-      if (item.type === 'notice') {
-        var notices = this.notices || []
-        for (var ni = 0; ni < notices.length; ni++) {
-          var raw = notices[ni] && notices[ni].notice || {}
-          if (raw.id !== undefined && ('notice-' + raw.id) === key) {
-            notices.splice(ni, 1)
-            break
-          }
-        }
-      }
-      // 系统通知:服务端标记已读(下次加载不再进入动态)
-      if (item.type === 'notification' && item._srcId) {
-        readNotification({ id: item._srcId }).catch(function() {})
-      }
-    },
     // ===== 能量/积分/CF值变更通知 =====
     loadEnergyPointsCFChanges: function() {
       var self = this
@@ -1246,6 +1150,9 @@ title: '添加家庭成员',
         self.energyMessages = []
         self.pointsMessages = []
         self.cfMessages = []
+        self.showEnergyBadge = false
+        self.showPointsBadge = false
+        self.showCfBadge = false
         return
       }
       
@@ -1262,21 +1169,30 @@ title: '添加家庭成员',
         // 处理能量变更
         if (energyRes) {
           self.energyMessages = energyRes.messages
+          self.showEnergyBadge = energyRes.showBadge
         }
         
         // 处理积分变更
         if (pointsRes) {
           self.pointsMessages = pointsRes.messages
+          self.showPointsBadge = pointsRes.showBadge
         }
         
         // 处理CF值变更
         if (cfRes) {
           self.cfMessages = cfRes.messages
+          self.showCfBadge = cfRes.showBadge
         }
+        
+        // 通知栏滑入动画:数据加载完成后触发从下往上的展示
+        self.isNotificationVisible = true
       }).catch(function() {
         self.energyMessages = []
         self.pointsMessages = []
         self.cfMessages = []
+        self.showEnergyBadge = false
+        self.showPointsBadge = false
+        self.showCfBadge = false
       })
     },
     
@@ -1337,8 +1253,12 @@ title: '添加家庭成员',
             }
           }
           
-          // 存储到实例属性(合并展示于首页动态提醒)
+          // 存储到实例属性(供其他方法使用,或可单独暴露)
+          // 注意:这里的 messages 仅在该方法调用后有效,实际建议使用单独的数据存储
           self.energyMessages = messages
+          self.energyChangeTotal = totalAmount
+          self.energyChangeCount = count
+          self.showEnergyBadge = count > 0
         }
       }).catch(function() {})
     },
@@ -1403,6 +1323,9 @@ title: '添加家庭成员',
           }
           
           self.pointsMessages = messages
+          self.pointsChangeTotal = totalAmount
+          self.pointsChangeCount = count
+          self.showPointsBadge = count > 0
         }
       }).catch(function() {})
     },
@@ -1462,6 +1385,9 @@ title: '添加家庭成员',
               }
               
               self.cfMessages = messages
+              self.cfChangeTotal = totalAmount
+              self.cfChangeCount = count
+              self.showCfBadge = count > 0
             }
           }).catch(function() {})
         }
@@ -1483,14 +1409,43 @@ title: '添加家庭成员',
              date.getMonth() === today.getMonth() &&
              date.getFullYear() === today.getFullYear()
     },
-// ===== 时间格式化 =====
-    // 时间格式化 yyyy-MM-dd HH:mm(iOS 安全:经 utils/format.js parseDate 归一化)
-    formatTime: function(dateStr) {
-      var d = parseDate(dateStr)
-      if (!d) return ''
-      function pad2(n) { return n < 10 ? '0' + n : '' + n }
-      return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()) +
-        ' ' + pad2(d.getHours()) + ':' + pad2(d.getMinutes())
+    
+    // ===== 能量/积分/CF值变更通知点击处理 =====
+    // 点击通知内容 → 跳转详情页 + 隐藏通知(点击后不再显示)
+    handleNotificationClick: function() {
+      // 点击空白区域:若有多个通知,跳转第一个可见的
+      if (this.showEnergyBadge) {
+        this.onEnergyClick()
+      } else if (this.showPointsBadge) {
+        this.onPointsClick()
+      } else if (this.showCfBadge) {
+        this.onCfClick()
+      } else {
+        this.dismissAll()
+      }
+    },
+    onEnergyClick: function() {
+      var memberId = this.currentChildId || uni.getStorageSync('currentMemberId') || ''
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/energy/detail?memberId=' + memberId })
+    },
+    onPointsClick: function() {
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/rewards/rewards' })
+    },
+    onCfClick: function() {
+      this.dismissAll()
+      uni.navigateTo({ url: '/pages/promotion/index' })
+    },
+    // 关闭按钮:仅隐藏,不跳转
+    dismissAll: function() {
+      this.isNotificationVisible = false
+      this.showEnergyBadge = false
+      this.showPointsBadge = false
+      this.showCfBadge = false
+      this.energyMessages = []
+      this.pointsMessages = []
+      this.cfMessages = []
     },
     quickCompleteTask: function(task) {
       var self = this
@@ -1874,7 +1829,7 @@ title: '添加家庭成员',
           var gradientColors = [
             'linear-gradient(135deg, #10B981, #34D399)',
             'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
-            'linear-gradient(135deg, #F59E0B, #FBBF24)',
+            'linear-gradient(135deg, #8B5CF6, #C084FC)',
             'linear-gradient(135deg, #F97316, #FB923C)',
             'linear-gradient(135deg, #6366F1, #818CF8)'
           ]
@@ -1914,11 +1869,7 @@ title: '添加家庭成员',
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
     },
     handleLogin() {
-      this.openLoginSheet({ redirect: '/pages/index-home/index' })
-    },
-    // 登录成功:刷新首页登录态数据
-    onLoginSuccess() {
-      this.loadLoggedInData()
+      uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/index-home/index') })
     },
     async handleParentInvite(code) {
       // 延迟显示,等待登录完成
@@ -2380,7 +2331,7 @@ title: '添加家庭成员',
 }
 .section-more {
   font-size: 24rpx;
-  color: #F97316;
+  color: #5B9BD5;
 }
 .empty-state {
   display: flex;
@@ -2707,7 +2658,7 @@ title: '添加家庭成员',
 /* ===== ① 了解区 — 自测大卡(引导"从了解开始") ===== */
 .self-test-card {
   margin: 20rpx 30rpx 16rpx;
-  background: linear-gradient(135deg, #F97316, #FB923C);
+  background: linear-gradient(135deg, #667eea, #764ba2);
   border-radius: 20rpx;
   padding: 24rpx 28rpx;
 }
@@ -2775,7 +2726,7 @@ title: '添加家庭成员',
 .guide-bar-purple .guide-bar-icon-wrap { background: rgba(139,92,246,0.12); }
 .guide-bar-purple .guide-bar-title { color: #4C1D95; }
 .guide-bar-purple .guide-bar-sub { color: #6D28D9; }
-.guide-bar-purple .guide-bar-btn { background: linear-gradient(135deg, #F97316, #FB923C); color: #fff; }
+.guide-bar-purple .guide-bar-btn { background: linear-gradient(135deg, #8B5CF6, #A78BFA); color: #fff; }
 
 .guide-bar-gray { background: #F8FAFC; border: 1rpx solid rgba(100,116,139,0.15); }
 .guide-bar-gray .guide-bar-icon-wrap { background: rgba(100,116,139,0.10); }
@@ -2909,4 +2860,41 @@ title: '添加家庭成员',
   border: none;
 }
 .self-test-badge { display: inline-block; align-self: flex-start; margin-top: 8rpx; font-size: 20rpx; color: #F97316; background: rgba(249,115,22,0.1); padding: 4rpx 14rpx; border-radius: 999rpx; }
+
+/* ===== 能量/积分/CF值变更通知条(从下滑入动画) ===== */
+.change-notification-bar {
+  position: fixed;
+  bottom: -80rpx;
+  left: 30rpx;
+  right: 30rpx;
+  padding: 16rpx 24rpx;
+  background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
+  border-radius: 16rpx;
+  box-shadow: 0 2rpx 8rpx rgba(249,115,22,0.12);
+  transition: bottom 0.3s cubic-bezier(0.16,1,0.3,1) forwards;
+  z-index: 100;
+}
+.change-notification-bar.visible {
+  bottom: 0;
+  animation: slide-up-notice 0.4s cubic-bezier(0.16,1,0.3,1) forwards;
+}
+@keyframes slide-up-notice {
+  from { opacity: 0; transform: translateY(80rpx); }
+  to   { opacity: 1; transform: translateY(0); }
+}
+.change-item {
+  font-size: 22rpx;
+  font-weight: 500;
+  color: #333;
+}
+.change-item.energy { color: #F97316; }
+.change-item.points { color: #10B981; }
+.change-item.cf { color: #6366F1; }
+.change-dismiss {
+  margin-left: auto;
+  font-size: 24rpx;
+  color: #999;
+  flex-shrink: 0;
+  cursor: pointer;
+}
 </style>

+ 11 - 9
cfc-frontend/pages/invite/join-form.vue

@@ -177,8 +177,8 @@ export default {
 	margin-bottom: 30rpx;
 }
 .form-item {
-	margin-bottom: 30rpx;
-}
+				margin-bottom: 40rpx;
+			}
 .form-item:last-child {
 	margin-bottom: 0;
 }
@@ -192,13 +192,15 @@ export default {
 	color: #f44336;
 }
 .input, .picker-input {
-	padding: 20rpx;
-	background: #f5f5f5;
-	border-radius: 12rpx;
-	font-size: 28rpx;
-	box-sizing: border-box;
-	width: 100%;
-}
+				padding: 20rpx;
+				background: #f5f5f5;
+				border-radius: 12rpx;
+				font-size: 28rpx;
+				box-sizing: border-box;
+				width: 100%;
+				min-height: 72rpx;
+				line-height: 1.5;
+			}
 .avatar-upload {
 	width: 150rpx;
 	height: 150rpx;

+ 54 - 8
cfc-frontend/pages/invite/join.vue

@@ -82,7 +82,7 @@
 </template>
 
 <script>
-import { validateInvitation, validateFamilyCode, checkUserFamily, bindReferral } from '@/utils/api.js'
+import { validateInvitation, validateFamilyCode, checkUserFamily, bindReferral, getMyJoinRequest } from '@/utils/api.js'
 
 export default {
   data() {
@@ -99,6 +99,7 @@ export default {
       isLoggedIn: false,
       inviteInfo: null,
       familyStatus: null,
+      joinRequest: null,
       error: '',
       // 动态文案
       tipText: '',
@@ -224,13 +225,11 @@ export default {
             this.$store && this.$store.commit('setFamilyId', res.data.familyId)
             uni.setStorageSync('familyId', res.data.familyId)
           }
-          // 已在目标家庭 -> 直接进首页
-          if (this.inviteInfo && this.familyStatus.inFamily && 
+          // 已在目标家庭
+          if (this.inviteInfo && this.familyStatus.inFamily &&
               this.familyStatus.familyId === this.inviteInfo.familyId) {
-            this.setRedirectInfo(true)
-            this.loading = false
-            // 延迟跳转,让用户看到成功提示
-            setTimeout(() => this.goHome(), 1000)
+            // 检查加入请求状态
+            await this.checkJoinRequestStatus()
             return
           }
           // 不在目标家庭 -> 去完善个人信息页提交申请
@@ -247,6 +246,45 @@ export default {
       }
     },
 
+    // 检查加入请求状态
+    async checkJoinRequestStatus() {
+      try {
+        var reqRes = await getMyJoinRequest()
+        if (reqRes && reqRes.data) {
+          var req = reqRes.data
+          if (req && req.status === 'pending') {
+            // 已提交申请但等待审批
+            this.tipText = '您的加入申请已提交,等待邀请人同意'
+            this.btnText = '查看申请状态'
+            this.joinRequest = req
+            this.loading = false
+            return
+          } else if (req && req.status === 'approved') {
+            // 已批准,直接进入成员管理页面
+            this.loading = false
+            uni.redirectTo({
+              url: '/pages/profile-extra/family-members'
+            })
+            return
+          } else if (req && req.status === 'rejected') {
+            // 已拒绝
+            this.tipText = '您的加入申请已被拒绝,如需重新申请请联系邀请人'
+            this.btnText = '返回首页'
+            this.loading = false
+            return
+          }
+        }
+        // 无请求记录,视为已是正式成员
+        this.setRedirectInfo(true)
+        this.loading = false
+      } catch (e) {
+        console.log('检查加入请求状态失败', e)
+        // 降级处理:直接进入首页
+        this.setRedirectInfo(true)
+        this.loading = false
+      }
+    },
+
     setRedirectInfo(isInTargetFamily) {
       if (isInTargetFamily) {
         this.tipText = '您已是该家庭成员'
@@ -262,9 +300,17 @@ export default {
       if (this.redirecting) return
       this.redirecting = true
 
-      // 已在目标家庭 -> 直接进首页
+      // 已在目标家庭且请求已批准 -> 直接进入成员管理页面
       if (this.inviteInfo && this.familyStatus && this.familyStatus.inFamily &&
           this.familyStatus.familyId === this.inviteInfo.familyId) {
+        if (this.joinRequest && this.joinRequest.status === 'approved') {
+          this.redirecting = false
+          uni.redirectTo({
+            url: '/pages/profile-extra/family-members'
+          })
+          return
+        }
+        // 已在家庭但请求 pending,不重复跳转,直接返回首页
         this.redirecting = false
         this.goHome()
         return

+ 1 - 1
cfc-frontend/pages/profile/service-role-apply.vue

@@ -261,7 +261,7 @@ export default {
   gap: 20rpx;
 }
 .role-card {
-  width: calc(33.33% - 14rpx);
+  width: calc((100% - 40rpx) / 3);
   background: #f8f8f8;
   border-radius: 12rpx;
   padding: 24rpx 0;

+ 47 - 206
cfc-frontend/pages/reminder-config/index.vue

@@ -57,6 +57,31 @@
         <text class="offset-hint" v-if="offsetHint">{{ offsetHint }}</text>
       </view>
 
+      <!-- 完成表扬开关 -->
+      <view class="cfg-card">
+        <view class="cfg-card-header">
+          <text class="cfg-card-title">任务完成鼓励</text>
+          <text class="cfg-switch" :class="{ 'cfg-switch-on': completionPraiseEnabled === 1 }" @tap="togglePraiseEnabled">
+            <text class="cfg-switch-text">{{ completionPraiseEnabled === 1 ? '已开启' : '已关闭' }}</text>
+          </text>
+        </view>
+        <text class="cfg-card-desc">任务正常完成时,发送表扬鼓励消息</text>
+        <view class="channel-list" v-if="completionPraiseEnabled === 1" style="margin-top:20rpx">
+          <view class="channel-item" v-for="ch in channels" :key="'p-'+ch.type"
+            :class="{
+              'channel-selected': isPraiseSelected(ch.type),
+              'channel-disabled': !ch.available
+            }"
+            @tap="togglePraiseChannel(ch)">
+            <view class="channel-check" v-if="isPraiseSelected(ch.type) && ch.available">✓</view>
+            <view class="channel-info">
+              <text class="channel-name">{{ ch.label }}</text>
+              <text class="channel-reason" v-if="!ch.available">{{ ch.reason || '未开通' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
       <!-- 站内消息兜底说明 -->
       <view class="tip-card">
         <text class="tip-text">💡 站内消息提醒始终开启,作为兜底通道不随以上设置关闭</text>
@@ -85,6 +110,8 @@ export default {
       selectedChannels: [],
       timingMode: 'after_timeout',
       offsetMinutes: 60,
+      completionPraiseEnabled: 0,
+      selectedPraiseChannels: ['wechat', 'in_app'],
       _loaded: false
     }
   },
@@ -124,6 +151,8 @@ export default {
         self.channels = Array.isArray(d.availableChannels) ? d.availableChannels.filter(function(c) {
           return c && c.type !== 'in_app'
         }) : []
+        self.completionPraiseEnabled = d.completionPraiseEnabled === 1 ? 1 : 0
+        self.selectedPraiseChannels = Array.isArray(d.completionPraiseChannels) ? d.completionPraiseChannels.slice() : ['wechat', 'in_app']
       }
     }).catch(function() {
       self.loading = false
@@ -134,9 +163,15 @@ export default {
     toggleEnabled: function() {
       this.enabled = this.enabled === 1 ? 0 : 1
     },
+    togglePraiseEnabled: function() {
+      this.completionPraiseEnabled = this.completionPraiseEnabled === 1 ? 0 : 1
+    },
     isSelected: function(type) {
       return (this.selectedChannels || []).indexOf(type) >= 0
     },
+    isPraiseSelected: function(type) {
+      return (this.selectedPraiseChannels || []).indexOf(type) >= 0
+    },
     toggleChannel: function(ch) {
       if (!ch.available) return
       var idx = this.selectedChannels.indexOf(ch.type)
@@ -146,6 +181,15 @@ export default {
         this.selectedChannels.push(ch.type)
       }
     },
+    togglePraiseChannel: function(ch) {
+      if (!ch.available) return
+      var idx = this.selectedPraiseChannels.indexOf(ch.type)
+      if (idx >= 0) {
+        this.selectedPraiseChannels.splice(idx, 1)
+      } else {
+        this.selectedPraiseChannels.push(ch.type)
+      }
+    },
     switchTiming: function(mode) {
       this.timingMode = mode
     },
@@ -171,7 +215,9 @@ export default {
         channels: this.selectedChannels.slice(),
         timingMode: this.timingMode,
         offsetMinutes: offset,
-        enabled: this.enabled === 1 ? 1 : 0
+        enabled: this.enabled === 1 ? 1 : 0,
+        completionPraiseEnabled: this.completionPraiseEnabled === 1 ? 1 : 0,
+        completionPraiseChannels: this.selectedPraiseChannels.slice()
       }).then(function(res) {
         self.saving = false
         if (res && res.code === 200) {
@@ -190,208 +236,3 @@ export default {
   }
 }
 </script>
-
-<style scoped>
-.reminder-config-page {
-  box-sizing: border-box;
-  min-height: 100vh;
-  background: #F5F9FC;
-  overflow: hidden;
-}
-.content {
-  padding: 30rpx;
-  box-sizing: border-box;
-  width: 100%;
-  overflow: hidden;
-}
-.cfg-card {
-  background-color: #FFFFFF;
-  border-radius: 24rpx;
-  padding: 30rpx;
-  margin-bottom: 30rpx;
-  box-shadow: 0 4rpx 16rpx rgba(91, 155, 213, 0.08);
-}
-.cfg-card-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-}
-.cfg-card-title {
-  font-size: 30rpx;
-  font-weight: 600;
-  color: #1E293B;
-}
-.cfg-card-desc {
-  display: block;
-  margin-top: 12rpx;
-  font-size: 24rpx;
-  color: #94A3B8;
-  line-height: 1.6;
-}
-.cfg-switch {
-  padding: 10rpx 28rpx;
-  border-radius: 999rpx;
-  font-size: 26rpx;
-  background: #E2E8F0;
-  color: #64748B;
-}
-.cfg-switch-on {
-  background: linear-gradient(135deg, #5B9BD5, #8FC5E8);
-  color: #FFFFFF;
-  font-weight: 600;
-}
-.channel-list {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 20rpx;
-  margin-top: 24rpx;
-}
-.channel-item {
-  box-sizing: border-box;
-  position: relative;
-  display: flex;
-  align-items: center;
-  padding: 16rpx 24rpx;
-  border: 2rpx solid #E2E8F0;
-  border-radius: 20rpx;
-  background: #FFFFFF;
-  min-width: 200rpx;
-}
-.channel-selected {
-  border-color: #5B9BD5;
-  background: #5B9BD5;
-}
-.channel-disabled {
-  opacity: 0.55;
-}
-.channel-check {
-  position: absolute;
-  top: -8rpx;
-  right: -8rpx;
-  width: 32rpx;
-  height: 32rpx;
-  border-radius: 50%;
-  background-color: #FFFFFF;
-  color: #5B9BD5;
-  font-size: 20rpx;
-  font-weight: 900;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 2;
-  box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
-}
-.channel-info {
-  display: flex;
-  flex-direction: column;
-  gap: 6rpx;
-}
-.channel-name {
-  font-size: 26rpx;
-  color: #1E293B;
-  font-weight: 500;
-}
-.channel-selected .channel-name {
-  color: #FFFFFF;
-}
-.channel-reason {
-  font-size: 20rpx;
-  color: #94A3B8;
-}
-.channel-tip {
-  width: 100%;
-  margin-top: 16rpx;
-}
-.channel-tip-text {
-  font-size: 24rpx;
-  color: #5B9BD5;
-}
-.timing-list {
-  display: flex;
-  gap: 20rpx;
-  margin-top: 24rpx;
-}
-.timing-item {
-  flex: 1;
-  box-sizing: border-box;
-  padding: 24rpx;
-  border: 2rpx solid #E2E8F0;
-  border-radius: 20rpx;
-  display: flex;
-  flex-direction: column;
-  gap: 8rpx;
-}
-.timing-active {
-  border-color: #5B9BD5;
-  background: #F0F7FF;
-}
-.timing-name {
-  font-size: 28rpx;
-  font-weight: 600;
-  color: #1E293B;
-}
-.timing-active .timing-name {
-  color: #3A7CC4;
-}
-.timing-desc {
-  font-size: 22rpx;
-  color: #94A3B8;
-}
-.offset-row {
-  display: flex;
-  align-items: center;
-  margin-top: 30rpx;
-  padding: 20rpx 24rpx;
-  background: #F8FAFC;
-  border-radius: 16rpx;
-}
-.offset-label {
-  font-size: 26rpx;
-  color: #1E293B;
-  margin-right: 20rpx;
-}
-.offset-input {
-  flex: 1;
-  font-size: 28rpx;
-  color: #1E293B;
-}
-.offset-unit {
-  font-size: 24rpx;
-  color: #94A3B8;
-  margin-left: 12rpx;
-}
-.offset-hint {
-  display: block;
-  margin-top: 12rpx;
-  font-size: 22rpx;
-  color: #94A3B8;
-}
-.tip-card {
-  background: #D6EAF8;
-  border-radius: 16rpx;
-  padding: 24rpx 30rpx;
-  margin-bottom: 30rpx;
-}
-.tip-text {
-  font-size: 24rpx;
-  color: #3A7CC4;
-  line-height: 1.6;
-}
-.save-area {
-  padding: 40rpx 30rpx;
-}
-.save-btn {
-  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
-  color: #FFFFFF;
-  text-align: center;
-  padding: 28rpx 0;
-  border-radius: 999rpx;
-  font-size: 30rpx;
-  font-weight: 600;
-  box-shadow: 0 6rpx 20rpx rgba(91, 155, 213, 0.3);
-}
-.save-btn:active {
-  opacity: 0.9;
-  transform: scale(0.98);
-}
-</style>

+ 3 - 20
cfc-frontend/pages/vendor/product-edit/product-edit.vue

@@ -41,14 +41,7 @@
         <textarea class="form-textarea" v-model="form.description" placeholder="商品描述(可选,详细内容)" />
       </view>
 
-      <view class="form-item">
-        <text class="form-label">仅供应商可见</text>
-        <switch :checked="form.vendorVisible" @change="onVendorVisibleChange" color="#F97316" />
-        <text class="form-hint">开启后,该商品仅供应商体系内用户可在列表中看到,直接分享链接仍可访问</text>
-      </view>
-    </view>
-
-    <view class="form-actions">
+<view class="form-actions">
       <button class="btn-submit" :disabled="submitting" @click="onSubmit">
         {{ submitting ? '提交中...' : (isEdit ? '保存修改' : '立即发布') }}
       </button>
@@ -80,8 +73,7 @@ export default {
         stock: 0,
         coverImage: '',
         intro: '',
-        description: '',
-        vendorVisible: false
+        description: ''
       }
     }
   },
@@ -97,13 +89,6 @@ export default {
   },
   methods: {
     onTypeChange(e) {
-      this.typeIndex = e.detail.value
-      this.form.productType = this.typeOptions[e.detail.value].value
-    },
-    onVendorVisibleChange(e) {
-      this.form.vendorVisible = e.detail.value
-    },
-    loadProduct(id) {
       uni.showLoading({ title: '加载中...' })
       productDetail({ id: Number(id) }).then(res => {
         uni.hideLoading()
@@ -116,7 +101,6 @@ export default {
           this.form.price = p.price || 0
           this.form.stock = p.stock || 0
           this.form.productType = p.productType || 'physical'
-          this.form.vendorVisible = p.vendorVisible === true
           // sync picker index
           var idx = this.typeOptions.findIndex(function(t) { return t.value === this.form.productType }, this)
           if (idx >= 0) this.typeIndex = idx
@@ -149,8 +133,7 @@ export default {
         stock: Number(this.form.stock),
         coverImage: this.form.coverImage || '',
         intro: this.form.intro || '',
-        description: this.form.description || '',
-        vendorVisible: this.form.vendorVisible
+        description: this.form.description || ''
       }
       var apiCall = this.isEdit
         ? productUpdate(Object.assign({ id: this.productId }, data))

+ 9 - 2
cfc-frontend/utils/api.js

@@ -3405,5 +3405,12 @@ export const getIndicatorChangeRanking = (params) => request('/api/indicator/cha
 export const getIndicatorQuickLookup = (params) => request('/api/indicator/quick-lookup', 'POST', params || {})
 /** 指标关联查询:{ subjectId, definitionId }(本期仅封装,展示二期) */
 export const getIndicatorCorrelationList = (params) => request('/api/indicator/correlation/list', 'POST', params || {})
-/** 成员已有指标时序的报告类型列表(曲线页类型筛选动态化):{ subjectId } → [{ sourceType, label }] */
-export const getIndicatorSourceTypes = (params) => request('/api/indicator/source-types', 'POST', params || {})
+
+// 管理端:任务提醒配置
+export function getReminderConfigByUser(userId) {
+  return request(/api/admin/task/reminder-config/get?userId= + userId, POST, {})
+}
+
+export function saveReminderConfigByUser(data) {
+  return request(/api/admin/task/reminder-config/save, POST, data)
+}

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-096842156b4286365878138b40e7185a3035148a
+d2cb89ebef7a38fed3584ea9c1891400cc7e16d0

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1566",
+  "version": "1.0.1579",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.1566",
+      "version": "1.0.1579",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.1567",
+  "version": "1.0.1580",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 133 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,139 @@
 
 ---
 
+## v1.0.1580 (2026-10-12)
+
+### Bug 修复
+- 移除 ProductEdit.vue 重复 if 行,修复编译失败
+
+
+## v1.0.1579 (2026-10-12)
+
+### 新功能
+- preserve pagination page when editing products
+
+### 其他
+- - ProductEdit.vue: read page from route query and redirect back with preserved page
+- - Fixes issue where editing a product on page 2 would redirect back to page 1 after save
+- 
+
+
+## v1.0.1578 (2026-10-11)
+
+### Bug 修复
+- 修复三个编辑页 flex 约束和滚动问题
+
+### 其他
+- - 统一 .edit 根元素、>.el-card 以及 .el-card__body 为 flex + min-height:0
+- - el-card__body 增加 overflow: hidden,防止卡片内容被裁剪或产生横向滚动
+- - 与 Layout/admin-tokens 的 .admin-page 弹性约束保持一致,确保长表单可内部滚动
+- 
+
+
+## v1.0.1577 (2026-10-11)
+
+### 新功能
+- 修改通知列表只显示未读,已读不计入首页familyFeed顶部
+
+### 其他
+- - 已读通知仍可在 消息历史 页面的「全部」筛选中查看
+- - 平台公告 (/api/admin/notices/all) 逻辑不受影响
+- 
+
+
+## v1.0.1576 (2026-10-11)
+
+### 其他
+- Make vendor optional in products: vendorId/vendorName/vendorVisible now nullable; remove vendorVisible switch from product edit form; preserve supplier system infrastructure
+
+
+## v1.0.1575 (2026-10-11)
+
+### Bug 修复
+- 邀请接受流程缺失 FamilyMember 记录和 avatar 字段
+
+### 其他
+- - FamilyJoinRequestService.approveJoinRequest(): 审批通过补偿创建 FamilyMember 记录,同头像策略
+- - 两处均检查重复记录(避免 mergeFamily 切换路径下插入双份)
+- - 新增 FamilyMemberMapper @Resource 注入
+- Closes: member avatar not showing after family invite join
+- 
+
+
+## v1.0.1574 (2026-10-11)
+
+### 重构
+- 将保存和取消按钮移至顶部操作栏
+
+
+## v1.0.1573 (2026-10-11)
+
+### Bug 修复
+- 商品编辑页移除横向滚动条
+
+### 其他
+- - 卡片 body 增加 width: 100% 和 box-sizing: border-box,防止 padding 导致横向溢出
+- 
+
+
+## v1.0.1572 (2026-10-11)
+
+### Bug 修复
+- 修复商品编辑页内容显示不全无法滑动问题
+
+### 其他
+- - el-card__body 增加 overflow: hidden,配合 .product-edit-form-wrap 的 overflow-y: auto 实现内部滚动
+- - 移除重复的 el-card__body 样式定义,保持 header 固定
+- - 与 Layout/admin-tokens 的 .admin-page 弹性约束保持一致
+- 
+
+
+## v1.0.1571 (2026-10-11)
+
+### Bug 修复
+- switchMemberToFamily 兼容孤儿成员记录,审批加入不再 500
+
+
+## v1.0.1570 (2026-10-11)
+
+### 新功能
+- 浠艾福桌面宠物 v1.0 完成(WPF + WebView2 架构)
+- 桌面宠物设计文档 v1.1 - 支持用户自定义状态
+- 桌面宠物设计文档 v1.0
+
+### 其他
+- - 架构: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
+- 
+
+### 文档
+- 桌面宠物实现计划 v1.0
+
+
+## v1.0.1569 (2026-10-11)
+
+### 新功能
+- 添加任务完成表扬通知(后端触发、配置字段、模板、管理端API/UI、H5设置页)
+
+
+## v1.0.1568 (2026-10-10)
+
+### Bug 修复
+- 食材查询适合/全部固定倒序(推荐值大的在前)、查询条件固定顶部、列表独立滚动
+- 商品编辑页表单加内滚动容器,解决超长内容无法下翻
+
+
 ## v1.0.1567 (2026-10-10)
 
 ### 其他

+ 134 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.1567
+> 当前版本: v1.0.1580
 
 ## 历史版本
 
@@ -8,6 +8,139 @@
 
 ---
 
+## v1.0.1580 (2026-10-12)
+
+### Bug 修复
+- 移除 ProductEdit.vue 重复 if 行,修复编译失败
+
+
+## v1.0.1579 (2026-10-12)
+
+### 新功能
+- preserve pagination page when editing products
+
+### 其他
+- - ProductEdit.vue: read page from route query and redirect back with preserved page
+- - Fixes issue where editing a product on page 2 would redirect back to page 1 after save
+- 
+
+
+## v1.0.1578 (2026-10-11)
+
+### Bug 修复
+- 修复三个编辑页 flex 约束和滚动问题
+
+### 其他
+- - 统一 .edit 根元素、>.el-card 以及 .el-card__body 为 flex + min-height:0
+- - el-card__body 增加 overflow: hidden,防止卡片内容被裁剪或产生横向滚动
+- - 与 Layout/admin-tokens 的 .admin-page 弹性约束保持一致,确保长表单可内部滚动
+- 
+
+
+## v1.0.1577 (2026-10-11)
+
+### 新功能
+- 修改通知列表只显示未读,已读不计入首页familyFeed顶部
+
+### 其他
+- - 已读通知仍可在 消息历史 页面的「全部」筛选中查看
+- - 平台公告 (/api/admin/notices/all) 逻辑不受影响
+- 
+
+
+## v1.0.1576 (2026-10-11)
+
+### 其他
+- Make vendor optional in products: vendorId/vendorName/vendorVisible now nullable; remove vendorVisible switch from product edit form; preserve supplier system infrastructure
+
+
+## v1.0.1575 (2026-10-11)
+
+### Bug 修复
+- 邀请接受流程缺失 FamilyMember 记录和 avatar 字段
+
+### 其他
+- - FamilyJoinRequestService.approveJoinRequest(): 审批通过补偿创建 FamilyMember 记录,同头像策略
+- - 两处均检查重复记录(避免 mergeFamily 切换路径下插入双份)
+- - 新增 FamilyMemberMapper @Resource 注入
+- Closes: member avatar not showing after family invite join
+- 
+
+
+## v1.0.1574 (2026-10-11)
+
+### 重构
+- 将保存和取消按钮移至顶部操作栏
+
+
+## v1.0.1573 (2026-10-11)
+
+### Bug 修复
+- 商品编辑页移除横向滚动条
+
+### 其他
+- - 卡片 body 增加 width: 100% 和 box-sizing: border-box,防止 padding 导致横向溢出
+- 
+
+
+## v1.0.1572 (2026-10-11)
+
+### Bug 修复
+- 修复商品编辑页内容显示不全无法滑动问题
+
+### 其他
+- - el-card__body 增加 overflow: hidden,配合 .product-edit-form-wrap 的 overflow-y: auto 实现内部滚动
+- - 移除重复的 el-card__body 样式定义,保持 header 固定
+- - 与 Layout/admin-tokens 的 .admin-page 弹性约束保持一致
+- 
+
+
+## v1.0.1571 (2026-10-11)
+
+### Bug 修复
+- switchMemberToFamily 兼容孤儿成员记录,审批加入不再 500
+
+
+## v1.0.1570 (2026-10-11)
+
+### 新功能
+- 浠艾福桌面宠物 v1.0 完成(WPF + WebView2 架构)
+- 桌面宠物设计文档 v1.1 - 支持用户自定义状态
+- 桌面宠物设计文档 v1.0
+
+### 其他
+- - 架构: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
+- 
+
+### 文档
+- 桌面宠物实现计划 v1.0
+
+
+## v1.0.1569 (2026-10-11)
+
+### 新功能
+- 添加任务完成表扬通知(后端触发、配置字段、模板、管理端API/UI、H5设置页)
+
+
+## v1.0.1568 (2026-10-10)
+
+### Bug 修复
+- 食材查询适合/全部固定倒序(推荐值大的在前)、查询条件固定顶部、列表独立滚动
+- 商品编辑页表单加内滚动容器,解决超长内容无法下翻
+
+
 ## v1.0.1567 (2026-10-10)
 
 ### 其他

+ 18 - 0
cfc-web/src/api/taskReminderConfig.js

@@ -0,0 +1,18 @@
+import request from '@/utils/request'
+
+// 管理端:获取指定用户的任务提醒配置
+export function getUserReminderConfig(userId) {
+  return request({
+    url: '/api/admin/task/reminder-config/get?userId=' + userId,
+    method: 'post'
+  })
+}
+
+// 管理端:保存指定用户的任务提醒配置
+export function saveUserReminderConfig(data) {
+  return request({
+    url: '/api/admin/task/reminder-config/save',
+    method: 'post',
+    data
+  })
+}

+ 87 - 0
cfc-web/src/views/Users.vue

@@ -134,6 +134,7 @@
                 更多<i class="el-icon-arrow-down el-icon--right"></i>
               </el-button>
               <el-dropdown-menu slot="dropdown">
+                <el-dropdown-item command="reminderConfig" icon="el-icon-setting">提醒设置</el-dropdown-item>
                 <el-dropdown-item command="resetPassword" icon="el-icon-key">重置密码</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
@@ -146,6 +147,40 @@
       </div>
 
       <!-- 分页 -->
+          <el-dialog title="任务提醒配置" :visible.sync="reminderDialogVisible" width="600px">
+      <el-form label-width="120px">
+        <el-form-item label="启用">
+          <el-switch v-model="reminderConfig.enabled" :active-value="1" :inactive-value="0"></el-switch>
+        </el-form-item>
+        <el-form-item label="提醒渠道">
+          <el-checkbox-group v-model="reminderConfig.channels">
+            <el-checkbox v-for="ch in (reminderConfig.availableChannels||[])" :key="ch.type" :label="ch.type" :disabled="ch.type==='in_app'">{{ ch.label }}</el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+        <el-form-item label="提醒时机">
+          <el-select v-model="reminderConfig.timingMode" placeholder="请选择">
+            <el-option label="截止前提醒" value="before_deadline"></el-option>
+            <el-option label="超时后提醒" value="after_timeout"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="偏移分钟">
+          <el-input-number v-model="reminderConfig.offsetMinutes" :min="1" :max="1440"></el-input-number>
+        </el-form-item>
+        <el-form-item label="完成表扬">
+          <el-switch v-model="reminderConfig.completionPraiseEnabled" :active-value="1" :inactive-value="0"></el-switch>
+        </el-form-item>
+        <el-form-item label="表扬渠道" v-if="reminderConfig.completionPraiseEnabled===1">
+          <el-checkbox-group v-model="reminderConfig.completionPraiseChannels">
+            <el-checkbox v-for="ch in (reminderConfig.availableChannels||[])" :key="'p'+ch.type" :label="ch.type" :disabled="ch.type==='in_app'">{{ ch.label }}</el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="reminderDialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="saveReminderConfig">保 存</el-button>
+      </span>
+    </el-dialog>
+
       <el-pagination
         style="margin-top: 20px; text-align: right"
         @size-change="handleSizeChange"
@@ -424,6 +459,58 @@ const res = await getUserList(params)
         }
       })
     },
+    handleReminderConfig(row) {
+      this.currentUser = row;
+      this.reminderConfig = {
+        channels: ["wechat"],
+        timingMode: "after_timeout",
+        offsetMinutes: 60,
+        enabled: 1,
+        completionPraiseEnabled: 0,
+        completionPraiseChannels: ["wechat", "in_app"]
+      };
+      this.reminderDialogVisible = true;
+      getUserReminderConfig(row.id).then(res => {
+        if (res.code === 200 && res.data) {
+          this.reminderConfig = {
+            channels: res.data.channels || ["wechat"],
+            timingMode: res.data.timingMode || "after_timeout",
+            offsetMinutes: res.data.offsetMinutes || 60,
+            enabled: res.data.enabled === 0 ? 0 : 1,
+            completionPraiseEnabled: res.data.completionPraiseEnabled === 1 ? 1 : 0,
+            completionPraiseChannels: res.data.completionPraiseChannels || ["wechat", "in_app"],
+            availableChannels: res.data.availableChannels || []
+          };
+        }
+      });
+    },
+    saveReminderConfig() {
+      if (!this.reminderConfig.channels || this.reminderConfig.channels.length === 0) {
+        this.$message.warning("请至少选择一个提醒渠道");
+        return;
+      }
+      if (this.reminderConfig.offsetMinutes < 1 || this.reminderConfig.offsetMinutes > 1440) {
+        this.$message.warning("偏移分钟需在 1-1440 之间");
+        return;
+      }
+      const data = {
+        userId: this.currentUser.id,
+        channels: this.reminderConfig.channels,
+        timingMode: this.reminderConfig.timingMode,
+        offsetMinutes: this.reminderConfig.offsetMinutes,
+        enabled: this.reminderConfig.enabled,
+        completionPraiseEnabled: this.reminderConfig.completionPraiseEnabled,
+        completionPraiseChannels: this.reminderConfig.completionPraiseChannels
+      };
+      saveUserReminderConfig(data).then(res => {
+        if (res.code === 200) {
+          this.$message.success("保存成功");
+          this.reminderDialogVisible = false;
+        } else {
+          this.$message.error(res.message || "保存失败");
+        }
+      });
+    },
     handleDelete(row) {
       this.$confirm(`确定要删除用户 "${row.nickname}" 吗?`, '提示', {
         confirmButtonText: '确定',

+ 16 - 7
cfc-web/src/views/admin/ActivityEdit.vue

@@ -541,18 +541,27 @@ activityId
 <style scoped>
 .activity-edit {
   padding: 20px;
-  display: block;
-  min-height: auto;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
 }
 .activity-edit > .el-card {
-  display: block;
-  min-height: auto;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
   width: 100%;
 }
 .activity-edit > .el-card /deep/ .el-card__body {
-  display: block;
-  min-height: auto;
-  overflow: visible;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+  padding: 24px;
+  width: 100%;
+  box-sizing: border-box;
 }
 .activity-edit /deep/ .el-form {
   width: 100%;

+ 16 - 7
cfc-web/src/views/admin/ArticleEdit.vue

@@ -810,18 +810,27 @@ onError: (file, err, res) => {
 <style scoped>
 .article-edit {
   padding: 20px;
-  display: block;
-  min-height: auto;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
 }
 .article-edit > .el-card {
-  display: block;
-  min-height: auto;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
   width: 100%;
 }
 .article-edit > .el-card /deep/ .el-card__body {
-  display: block;
-  min-height: auto;
-  overflow: visible;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+  padding: 24px;
+  width: 100%;
+  box-sizing: border-box;
 }
 .article-edit /deep/ .el-form {
   width: 100%;

+ 25 - 24
cfc-web/src/views/admin/ProductEdit.vue

@@ -4,7 +4,8 @@
       <div slot="header" class="admin-page-header">
         <span class="admin-page-title">{{ isEdit ? '编辑商品' : '新增商品' }}</span>
         <div class="admin-page-actions">
-          <el-button size="small" @click="$router.back()">返回</el-button>
+          <el-button type="primary" size="small" :loading="saving" @click="handleSave()">{{ isEdit ? '保存修改' : '创建商品' }}</el-button>
+          <el-button size="small" @click="$router.back()">取消</el-button>
         </div>
       </div>
 
@@ -341,12 +342,7 @@
           </div>
         </el-form-item>
 
-        <div class="edit-form-footer">
-          <el-button type="primary" :loading="saving" @click="handleSave()">
-{{ isEdit ? '保存修改' : '创建商品' }}
-          </el-button>
-          <el-button @click="$router.back()">取消</el-button>
-        </div>
+        <div class="edit-form-footer" style="display:none;"></div>
       </el-form>
       </div>
     </el-card>
@@ -1152,7 +1148,8 @@ async loadGiftRule(productId) {
               }
             }
             this.$message.success(this.isEdit ? '保存成功' : '创建成功')
-            this.$router.push('/product-manage')
+            const page = this.$route.query.page || 1
+            this.$router.push(`/product-manage?page=${page}`)
           } else {
             this.$message.error(res.message || '操作失败')
           }
@@ -1170,17 +1167,26 @@ async loadGiftRule(productId) {
 <style scoped>
 .product-edit {
   padding: 20px;
-  display: block;
-  min-height: auto;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
 }
 .product-edit > .el-card {
-  display: block;
-  min-height: auto;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
 }
 .product-edit > .el-card /deep/ .el-card__body {
-  display: block;
-  min-height: auto;
-  overflow: visible;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+  padding: 24px;
+  width: 100%;
+  box-sizing: border-box;
 }
 .cover-uploader {
   display: flex;
@@ -1263,6 +1269,8 @@ async loadGiftRule(productId) {
 }
 .product-edit .el-card {
   width: 100%;
+  flex: 1;
+  min-height: 0;
 }
 .product-edit .el-form {
   width: 100%;
@@ -1365,24 +1373,17 @@ async loadGiftRule(productId) {
   box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
 }
 /* 表单滚动容器:卡片锁定可视高度,表单区内部滚动(解决长表单无法下翻) */
-.product-edit /deep/ .el-card__body {
-  display: flex;
-  flex-direction: column;
-  min-height: 0;
-  padding: 24px;
-}
 .product-edit-form-wrap {
   flex: 1;
   min-height: 0;
   overflow-y: auto;
+  overflow-x: hidden;
   padding-right: 8px;
   margin-right: -8px;
 }
 .product-edit /deep/ .el-card__header {
   padding: 20px 24px;
-}
-.product-edit /deep/ .el-card__body {
-  padding: 24px;
+  flex-shrink: 0;
 }
 .product-edit /deep/ .admin-page-title {
   font-size: 20px;

+ 1 - 1
cfc-web/src/views/admin/ProductManage.vue

@@ -194,7 +194,7 @@
           </el-table-column>
           <el-table-column label="操作" width="176" fixed="right" align="left">
             <template slot-scope="{ row }">
-              <el-button type="text" size="small" @click="$router.push('/product-edit?id=' + row.id)">编辑</el-button>
+              <el-button type="text" size="small" @click="$router.push('/product-edit?id=' + row.id + '&page=' + this.pagination.page)">编辑</el-button>
               <el-button type="text" size="small" @click="showInventoryDialog(row)">库存</el-button>
               <el-dropdown trigger="click" @command="(cmd) => handleActionCmd(row, cmd)">
                 <el-button type="text" size="small">

+ 2112 - 0
docs/superpowers/plans/2026-10-10-desktop-pet.md

@@ -0,0 +1,2112 @@
+# 桌面宠物实现计划
+
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
+
+**目标:** 构建一款 Windows 桌面宠物应用,展现用户 CFC 系统的五维能量状态、任务完成情况,并支持与用户互动完成打卡任务(如喝水提醒、任务完成确认)。应用使用 WPF + WebView2 架构,支持用户自定义形象和自定义状态。
+
+**架构:** WPF (.NET 6) 主进程 + WebView2 渲染层。主进程负责 API 调用(CFC 后端 localhost:9082)、状态管理、托盘图标与 JWT 缓存;WebView2 负责精灵渲染、CSS 动画、用户交互。通过 `window.cfcPetAPI` Bridge 实现 C# ↔ JavaScript 双向通信。
+
+**技术栈:**
+- UI 框架:WPF (.NET 6, Windows 专用)
+- 渲染引擎:WebView2(Edge 内核,Win10+ 预装)
+- API 调用:HttpClient + HttpClientFactory,JWT 缓存
+- 动画:CSS Animation + JavaScript
+- 配置存储:JSON 文件 + 用户配置目录
+- 打包:MSIX / ClickOnce
+
+**构建环境:** WPF 仅支持 Windows,需在 Windows 机器(.NET 6 SDK)上 `dotnet build` / `dotnet test`。本仓库其余部分为 Java/前端,桌面宠物是独立新目录 `cfc-desktop-pet/`,不影响现有模块。
+
+**规格文档:** `docs/superpowers/specs/2026-10-10-desktop-pet-design.md`
+
+---
+## 任务 1:解决方案骨架 + WPF 主项目
+
+**文件:**
+- 创建:`cfc-desktop-pet/XafPet.Desktop.sln`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/App.xaml` / `App.xaml.cs`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml` / `MainWindow.xaml.cs`
+- 创建:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj`
+
+- [ ] **步骤 1:创建目录结构**
+
+```bash
+mkdir -p cfc-desktop-pet/src/XafPet.Desktop/PetEngine \
+         cfc-desktop-pet/src/XafPet.Desktop/Assets \
+         cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao \
+         cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao \
+         cfc-desktop-pet/tests/XafPet.Desktop.Tests
+```
+
+- [ ] **步骤 2:创建 WPF 项目文件**
+
+`cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj`:
+
+```xml
+<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>
+    <None Include="Assets\**" CopyToOutputDirectory="PreserveNewest" />
+    <None Include="DefaultPets\**" CopyToOutputDirectory="PreserveNewest" />
+  </ItemGroup>
+</Project>
+```
+
+- [ ] **步骤 3:创建 App 入口**
+
+`cfc-desktop-pet/src/XafPet.Desktop/App.xaml`:
+
+```xml
+<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>
+```
+
+`cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs`:
+
+```csharp
+using System.Windows;
+
+namespace XafPet.Desktop;
+
+public partial class App : Application
+{
+}
+```
+
+- [ ] **步骤 4:创建主窗口(透明置顶,占位内容)**
+
+`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml`:
+
+```xml
+<Window x:Class="XafPet.Desktop.MainWindow"
+        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
+        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+        Title="浠艾福桌面宠物"
+        Width="200" Height="200"
+        WindowStyle="None"
+        AllowsTransparency="True"
+        Background="Transparent"
+        Topmost="True"
+        ShowInTaskbar="False"
+        ResizeMode="NoResize">
+    <Grid x:Name="RootGrid">
+        <TextBlock Text="浠艾福桌面宠物"
+                   HorizontalAlignment="Center" VerticalAlignment="Center"
+                   Foreground="#4A9BD7" FontSize="14"/>
+    </Grid>
+</Window>
+```
+
+`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs`:
+
+```csharp
+using System.Windows;
+using System.Input.Mouse;
+
+namespace XafPet.Desktop;
+
+public partial class MainWindow : Window
+{
+    public MainWindow()
+    {
+        InitializeComponent();
+        RootGrid.MouseLeftButtonDown += (s, e) =>
+        {
+            if (e.ButtonState == MouseButtonState.Pressed) DragMove();
+        };
+    }
+}
+```
+
+- [ ] **步骤 5:创建测试项目**
+
+`cfc-desktop-pet/tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj`:
+
+```xml
+<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>
+```
+
+- [ ] **步骤 6:创建解决方案文件并添加项目**
+
+```bash
+cd cfc-desktop-pet
+dotnet new sln -n XafPet.Desktop
+dotnet sln add src/XafPet.Desktop/XafPet.Desktop.csproj
+dotnet sln add tests/XafPet.Desktop.Tests/XafPet.Desktop.Tests.csproj
+```
+
+- [ ] **步骤 7:编译验证**
+
+```bash
+cd cfc-desktop-pet
+dotnet build XafPet.Desktop.sln
+```
+
+预期:`Build succeeded`,0 错误。
+
+- [ ] **步骤 8:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: 桌面宠物解决方案骨架 (WPF + WebView2 + xUnit)"
+```
+
+---
+
+## 任务 2:宠物状态枚举 + 形象配置模型
+
+**文件:**
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs`
+- 测试:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs`
+
+- [ ] **步骤 1:编写失败的测试**
+
+`cfc-desktop-pet/tests/XafPet.Desktop.Tests/PetConfigTests.cs`:
+
+```csharp
+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"));
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试确认失败**
+
+```bash
+cd cfc-desktop-pet
+dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~PetConfigTests" -v
+```
+
+预期:FAIL — `PetConfig` 类型不存在。
+
+- [ ] **步骤 3:实现 PetState.cs**
+
+`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetState.cs`:
+
+```csharp
+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            // 可选:任务列表/能量值等
+);
+```
+
+- [ ] **步骤 4:实现 PetConfig.cs**
+
+`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/PetConfig.cs`:
+
+```csharp
+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; // 秒
+}
+```
+
+- [ ] **步骤 5:运行测试验证通过**
+
+```bash
+cd cfc-desktop-pet
+dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~PetConfigTests" -v
+```
+
+预期:PASS — 4/4 通过。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: 宠物状态枚举 + 形象配置模型 (PetState/PetConfig)"
+```
+
+## 任务 3:CFC API 客户端
+
+**文件:**
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs`
+- 测试:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs`
+
+- [ ] **步骤 1:编写失败的测试**
+
+`cfc-desktop-pet/tests/XafPet.Desktop.Tests/ApiClientTests.cs`:
+
+```csharp
+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());
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试确认失败**
+
+```bash
+cd cfc-desktop-pet
+dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~ApiClientTests" -v
+```
+
+预期:FAIL — `ApiClient`/`ApiException` 不存在。
+
+- [ ] **步骤 3:实现 CfcApiModels.cs(API 响应 DTO)**
+
+`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/CfcApiModels.cs`:
+
+```csharp
+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;
+    }
+}
+```
+
+- [ ] **步骤 4:实现 ApiClient.cs**
+
+`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/ApiClient.cs`:
+
+```csharp
+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;
+    }
+}
+```
+
+- [ ] **步骤 5:运行测试验证通过**
+
+```bash
+cd cfc-desktop-pet
+dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~ApiClientTests" -v
+```
+
+预期:PASS — 5/5 通过。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: CFC API 客户端 (今日任务/能量概览/健康打卡)"
+```
+
+## 任务 4:状态管理器(API 数据 → 宠物状态)
+
+**文件:**
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs`
+- 测试:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs`
+
+- [ ] **步骤 1:编写失败的测试**
+
+`cfc-desktop-pet/tests/XafPet.Desktop.Tests/StateManagerTests.cs`:
+
+```csharp
+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));
+    }
+}
+```
+
+- [ ] **步骤 2:运行测试确认失败**
+
+```bash
+cd cfc-desktop-pet
+dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~StateManagerTests" -v
+```
+
+预期:FAIL — `StateManager` 不存在。
+
+- [ ] **步骤 3:实现 StateManager.cs**
+
+`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/StateManager.cs`:
+
+```csharp
+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());
+    }
+}
+```
+
+- [ ] **步骤 4:运行测试验证通过**
+
+```bash
+cd cfc-desktop-pet
+dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~StateManagerTests" -v
+```
+
+预期:PASS — 9/9 通过。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: 状态管理器 (API数据→宠物状态映射)"
+```
+
+## 任务 5:默认宠物资产(浠宝/福宝占位 PNG + 配置)
+
+**文件:**
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/standing.png`(1x1 透明占位)
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/standing.png`(1x1 透明占位)
+
+- [ ] **步骤 1:编写浠宝配置**
+
+`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao/pet-config.json`:
+
+```json
+{
+  "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
+  }
+}
+```
+
+- [ ] **步骤 2:编写福宝配置**
+
+`cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao/pet-config.json`:
+
+```json
+{
+  "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
+  }
+}
+```
+
+- [ ] **步骤 3:生成占位 PNG(1x1 透明)**
+
+```bash
+python3 -c "
+import struct, zlib
+def png_chunk(tag, data):
+    c = struct.pack('>I', len(data)) + tag + data
+    return c + struct.pack('>I', zlib.crc32(tag + data) & 0xffffffff)
+ihdr = struct.pack('>IIBBBBB', 1, 1, 8, 6, 0, 0, 0)
+raw = b'\x00' + b'\x00\x00\x00\x00'
+png = b'\x89PNG\r\n\x1a\n'
+png += png_chunk(b'IHDR', ihdr)
+png += png_chunk(b'IDAT', zlib.compress(raw))
+png += png_chunk(b'IEND', b'')
+for d in ['cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/xibao',
+          'cfc-desktop-pet/src/XafPet.Desktop/DefaultPets/fubao']:
+    open(d + '/standing.png', 'wb').write(png)
+    print('written', d + '/standing.png')
+"
+```
+
+- [ ] **步骤 4:验证配置可被 PetConfig 解析**
+
+```bash
+cd cfc-desktop-pet
+dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~PetConfigTests" -v
+```
+
+预期:PASS(回归确认)。
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: 默认宠物资产 (浠宝/福宝 占位配置)"
+```
+
+## 任务 6:WebView2 宿主 + Bridge 注册
+
+**文件:**
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml` / `WebViewHost.xaml.cs`
+- 修改:`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml` / `MainWindow.xaml.cs`
+
+- [ ] **步骤 1:创建 WebViewHost 控件(XAML)**
+
+`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml`:
+
+```xml
+<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>
+```
+
+- [ ] **步骤 2:实现 WebViewHost 代码(Bridge 注册 + 初始化)**
+
+`cfc-desktop-pet/src/XafPet.Desktop/PetEngine/WebViewHost.xaml.cs`:
+
+```csharp
+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; // action, dataJson
+
+    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);
+}
+
+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() { }
+}
+```
+
+- [ ] **步骤 3:创建 pet.html**
+
+`cfc-desktop-pet/src/XafPet.Desktop/Assets/pet.html`:
+
+```html
+<!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>
+```
+
+- [ ] **步骤 4:修改 MainWindow 使用 WebViewHost**
+
+`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml`:
+
+```xml
+<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>
+```
+
+`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs`:
+
+```csharp
+using System.Windows;
+using System.Windows.Input;
+
+namespace XafPet.Desktop;
+
+public partial class MainWindow : Window
+{
+    public MainWindow()
+    {
+        InitializeComponent();
+        RootGrid.MouseLeftButtonDown += (s, e) =>
+        {
+            if (e.ButtonState == MouseButtonState.Pressed) DragMove();
+        };
+        Loaded += async (_, _) =>
+        {
+            await Task.Delay(2000);
+            PetHost.NotifyState("{\"state\":\"happy\",\"reason\":\"welcome\"}");
+        };
+    }
+}
+```
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd cfc-desktop-pet
+dotnet build XafPet.Desktop.sln
+```
+
+预期:`Build succeeded`,0 错误。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: WebView2 宿主 + C#/JS Bridge 注册"
+```
+
+## 任务 7:宠物引擎 JS(状态机 + 动画)
+
+**文件:**
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js`
+
+- [ ] **步骤 1:创建 animations.css**
+
+`cfc-desktop-pet/src/XafPet.Desktop/Assets/animations.css`:
+
+```css
+/* ===== 浠艾福桌面宠物动画定义 ===== */
+@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; }
+```
+
+- [ ] **步骤 2:创建 pet-engine.js**
+
+`cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js`:
+
+```javascript
+(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);
+})();
+```
+
+- [ ] **步骤 3:创建 interactions.js**
+
+`cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js`:
+
+```javascript
+(function () {
+  'use strict';
+
+  const petEl = document.getElementById('pet');
+  const petImg = document.getElementById('pet-img');
+
+  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 */ }
+  }
+})();
+```
+
+- [ ] **步骤 4:语法检查 JS(若 node 不可用则跳过)**
+
+```bash
+node --check cfc-desktop-pet/src/XafPet.Desktop/Assets/pet-engine.js
+node --check cfc-desktop-pet/src/XafPet.Desktop/Assets/interactions.js
+```
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd cfc-desktop-pet
+dotnet build XafPet.Desktop.sln
+```
+
+预期:`Build succeeded`。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: 宠物引擎 JS (状态机+动画) + CSS 动画库"
+```
+
+## 任务 8:状态轮询与推送集成
+
+**文件:**
+- 修改:`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs`
+- 修改:`cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj`(添加 WindowsForms 引用)
+
+- [ ] **步骤 1:修改 csproj 添加 WindowsForms 引用**
+
+在 `XafPet.Desktop.csproj` 的 `<ItemGroup>` 追加:
+
+```xml
+  <ItemGroup>
+    <FrameworkReference Include="Microsoft.WindowsDesktop.App.WindowsForms" />
+  </ItemGroup>
+```
+
+- [ ] **步骤 2:实现完整 MainWindow(含轮询 + 交互回调)**
+
+`cfc-desktop-pet/src/XafPet.Desktop/MainWindow.xaml.cs`(完整替换):
+
+```csharp
+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) { /* 默认浠宝 */ }
+    }
+}
+```
+
+- [ ] **步骤 3:更新 App.xaml.cs 声明命名空间(WPF 所需)**
+
+`cfc-desktop-pet/src/XafPet.Desktop/App.xaml.cs`:
+
+```csharp
+using System.Windows;
+using System.Windows.Forms;  // NotifyIcon
+
+namespace XafPet.Desktop;
+
+public partial class App : Application
+{
+    protected override void OnStartup(StartupEventArgs e)
+    {
+        base.OnStartup(e);
+        // 确保单实例(避免重复启动)
+        if (!SingleInstanceHelper.TryEnterFirstInstance())
+        {
+            Current.Shutdown();
+            return;
+        }
+    }
+}
+```
+
+- [ ] **步骤 4:单实例辅助类**
+
+`cfc-desktop-pet/src/XafPet.Desktop/SingleInstanceHelper.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+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 = Process.GetCurrentProcess().ProcessName;
+        var instances = Process.GetProcessesByName(name);
+        if (instances.Length > 1)
+        {
+            SetForegroundWindow(instances[0].MainWindowHandle);
+            return false;
+        }
+        return true;
+    }
+}
+```
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd cfc-desktop-pet
+dotnet build XafPet.Desktop.sln
+```
+
+预期:`Build succeeded`。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: 状态轮询 + 托盘 + 喝水提醒闭环 + 单实例"
+```
+
+## 任务 9:Token 存储 + 设置窗口
+
+**文件:**
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs`
+- 创建:`cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml` / `SettingsWindow.xaml.cs`
+- 测试:`cfc-desktop-pet/tests/XafPet.Desktop.Tests/TokenStoreTests.cs`
+
+- [ ] **步骤 1:编写 TokenStore 测试**
+
+`cfc-desktop-pet/tests/XafPet.Desktop.Tests/TokenStoreTests.cs`:
+
+```csharp
+using System.IO;
+using Xunit;
+using XafPet.Desktop;
+
+namespace XafPet.Desktop.Tests;
+
+public class TokenStoreTests
+{
+    private string TestDir => Path.Combine(Path.GetTempPath(), "xptest_" + Guid.NewGuid().ToString("N"));
+
+    [Fact]
+    public void SaveAndLoad_RoundsTrip()
+    {
+        var path = Path.Combine(TestDir, "token.bin");
+        TokenStore.SetPath(path);
+        TokenStore.Save("my-jwt-token");
+        var loaded = TokenStore.Load();
+        Assert.Equal("my-jwt-token", loaded);
+        TokenStore.Clear();
+    }
+
+    [Fact]
+    public void Load_MissingFile_ReturnsNull()
+    {
+        TokenStore.SetPath(Path.Combine(TestDir, "nonexistent.bin"));
+        Assert.Null(TokenStore.Load());
+    }
+
+    [Fact]
+    public void Clear_RemovesFile()
+    {
+        var path = Path.Combine(TestDir, "token.bin");
+        TokenStore.SetPath(path);
+        TokenStore.Save("token");
+        TokenStore.Clear();
+        Assert.False(File.Exists(path));
+    }
+}
+```
+
+- [ ] **步骤 2:实现 TokenStore(注入路径,便于测试)**
+
+`cfc-desktop-pet/src/XafPet.Desktop/TokenStore.cs`:
+
+```csharp
+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);
+    }
+}
+```
+
+- [ ] **步骤 3:运行测试验证通过**
+
+```bash
+cd cfc-desktop-pet
+dotnet test tests/XafPet.Desktop.Tests --filter "FullyQualifiedName~TokenStoreTests" -v
+```
+
+预期:PASS — 3/3 通过。
+
+- [ ] **步骤 4:创建 SettingsWindow**
+
+`cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml`:
+
+```xml
+<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>
+```
+
+`cfc-desktop-pet/src/XafPet.Desktop/SettingsWindow.xaml.cs`:
+
+```csharp
+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();
+    }
+}
+```
+
+- [ ] **步骤 5:编译验证**
+
+```bash
+cd cfc-desktop-pet
+dotnet build XafPet.Desktop.sln
+```
+
+预期:`Build succeeded`。
+
+- [ ] **步骤 6:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: Token 存储 + 设置窗口"
+```
+
+## 任务 10:端到端集成验证 + 发布打包
+
+**文件:**
+- 修改:`cfc-desktop-pet/src/XafPet.Desktop/XafPet.Desktop.csproj`(发布配置)
+- 创建:`cfc-desktop-pet/README.md`
+
+- [ ] **步骤 1:添加发布配置(单文件 exe,Self-Contained)**
+
+在 `XafPet.Desktop.csproj` 追加:
+
+```xml
+  <PropertyGroup>
+    <PublishSingleFile>true</PublishSingleFile>
+    <SelfContained>true</SelfContained>
+    <RuntimeIdentifier>win-x64</RuntimeIdentifier>
+    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
+  </PropertyGroup>
+```
+
+- [ ] **步骤 2:完整构建 + 运行所有测试**
+
+```bash
+cd cfc-desktop-pet
+dotnet build XafPet.Desktop.sln -c Release
+dotnet test tests/XafPet.Desktop.Tests -c Release -v
+```
+
+预期:全部测试 PASS,0 构建错误。
+
+- [ ] **步骤 3:发布 exe**
+
+```bash
+cd cfc-desktop-pet
+dotnet publish src/XafPet.Desktop/XafPet.Desktop.csproj -c Release -r win-x64 --self-contained true
+```
+
+预期:输出位于 `src/XafPet.Desktop/bin/Release/net6.0-windows/win-x64/publish/XafPet.Desktop.exe`。
+
+- [ ] **步骤 4:编写 README**
+
+`cfc-desktop-pet/README.md`:
+
+```markdown
+# 浠艾福桌面宠物 (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
+dotnet test tests/XafPet.Desktop.Tests
+```
+```
+
+- [ ] **步骤 5:Commit**
+
+```bash
+git add cfc-desktop-pet/
+git commit -m "feat: 桌面宠物 v1.0 完成 (发布配置 + README)"
+```
+
+---
+
+## 自检清单
+
+| 规格章节 | 对应任务 | 状态 |
+|---------|---------|------|
+| 二、系统架构(WPF+WebView2) | 任务 1, 6 | ✅ |
+| 三、数据流(API→状态映射) | 任务 3, 4, 8 | ✅ |
+| 四、形象配置 JSON Schema | 任务 2, 5 | ✅ |
+| 五、交互规则与动作库 | 任务 7(CSS 动画 + JS 状态机) | ✅ |
+| 六.1 拖拽与置顶 | 任务 1(DragMove + Topmost) | ✅ |
+| 六.2 五维能量可视化 | 任务 7(颜色联动待增强) | ⚠️ 部分 |
+| 六.3 声音策略 | 任务 7(sounds 已定义,播放器待扩展) | ⚠️ 部分 |
+| 六.4 游戏模式 | 未覆盖 | ⚠️ 缺失(v1.1 迭代) |
+| 用户自定义状态 | 任务 2(PetConfig.CustomStateCount)+ 任务 7(setCustomState) | ✅ |
+| 顽皮行为(偷图标/吃文档) | 未覆盖 | ⚠️ 缺失(v1.1 迭代) |
+
+**类型一致性检查:**
+- `PetState` 枚举 → `StateManager.StateToCamelCase` → JS `setState` ✅
+- `PetConfig.Parse(json)` → `PetHost.LoadPetConfig()` → JS `loadConfig` ✅
+- `ApiClient.PostAsync<T>` → `ApiResult<T>` → `IsSuccess` ✅
+- `WebViewHost.NotifyState/SetCustomState/ShowBubble/LoadPetConfig` — 命名一致 ✅
+- `CfcHostBridge.onInteract` ↔ `interactions.js` `chrome.webview.hostObjects.sync.cfcHost.onInteract` ✅
+
+**占位符扫描:** 无 "待定"/"TODO"/"后续实现"。注释 `"TODO: 任务9"` 已随任务 9 实现移除。
+
+---
+
+## 执行交接
+
+计划已完成并保存到 `docs/superpowers/plans/2026-10-10-desktop-pet.md`。
+
+两种执行方式:
+
+**1. 子代理驱动(推荐)** — 每个任务调度一个新的子代理,任务间进行审查,快速迭代
+
+**2. 内联执行** — 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点
+
+选哪种方式?

+ 353 - 0
docs/superpowers/specs/2026-10-10-desktop-pet-design.md

@@ -0,0 +1,353 @@
+# 浠艾福桌面宠物设计文档
+
+> **日期:** 2026-10-10
+> **版本:** v1.0
+> **状态:** 设计确认
+
+---
+
+## 一、项目概述
+
+### 1.1 目标
+构建一款 Windows 桌面宠物应用,通过浠艾福品牌吉祥物(浠宝/福宝)展现用户当前健康状态与任务执行情况,同时支持与用户互动完成打卡任务。
+
+### 1.2 核心能力
+| 能力 | 说明 |
+|------|------|
+| **状态展示** | 实时反映五维能量值、任务完成进度、健康打卡状态 |
+| **互动提醒** | 支持喝水、吃药等定时提醒,用户可通过点击宠物确认完成 |
+| **形象自定义** | 支持上传 PNG 形象 + JSON 配置,用户可完全自定义 |
+| **小游戏** | 内置舒尔特方格等小游戏,宠物陪伴用户完成 |
+| **顽皮行为** | 支持"偷图标"、"吃文档"等随机互动,增强陪伴感 |
+
+---
+
+## 二、系统架构
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│                     WPF 主进程 (C#)                         │
+│                                                             │
+│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────┐  │
+│  │  API Client  │  │  状态管理器  │  │  托盘 / 快捷手势  │  │
+│  │  (HttpClient)│  │  (StateMgr)  │  │  (NotifyIcon)    │  │
+│  └──────┬───────┘  └──────┬───────┘  └────────┬─────────┘  │
+│         │                 │                    │            │
+│  ┌──────▼─────────────────▼────────────────────▼─────────┐  │
+│  │                  WebView2 渲染层                       │  │
+│  │  ┌──────────────────────────────────────────────────┐  │  │
+│  │  │  HTML/JS 宠物引擎                                 │  │  │
+│  │  │  - 精灵渲染 / CSS 动画 / 状态机                   │  │  │
+│  │  │  - 拖拽 / 点击 / 悬停交互                         │  │  │
+│  │  │  - 对话框气泡                                     │  │  │
+│  │  └──────────────────────────────────────────────────┘  │  │
+│  └────────────────────────────────────────────────────────┘  │
+│                                                             │
+│  ┌───────────────────────────────────────────────────────┐  │
+│  │            JS ↔ C# Bridge (CoreWebView2)              │  │
+│  │  C# → JS: window.cfcPetAPI.notifyState(data)         │  │
+│  │  JS → C#: await chrome.webview.hostObjects.sync...    │  │
+│  └───────────────────────────────────────────────────────┘  │
+└─────────────────────────────────────────────────────────────┘
+                            │
+                            ▼
+                  ┌──────────────────┐
+                  │  CFC Backend     │
+                  │  localhost:9082  │
+                  │  JWT Bearer Token│
+                  └──────────────────┘
+```
+
+### 模块职责
+
+| 模块 | 职责 |
+|------|------|
+| **API Client** | 定时轮询 / 事件触发时拉取任务、能量、打卡数据;JWT 缓存与刷新 |
+| **State Manager** | 将 API 数据转换为宠物状态(idle/happy/sad/busy/sleeping 等) |
+| **Bridge** | C# 调用 JS 函数推送状态,JS 回调 C# 处理点击/拖拽事件 |
+| **托盘 / 快捷手势** | 右键菜单(设置/最小化/退出)、键盘快捷键(显示/隐藏/唤醒) |
+| **WebView2 渲染层** | 接收状态数据,驱动精灵动画,处理用户交互 |
+
+---
+
+## 三、数据流(API → 宠物状态映射)
+
+### 数据来源
+
+| CFC API | 轮询频率 | 用途 |
+|---------|----------|------|
+| `POST /api/tasks/today` | 30s | 当前用户今日任务列表 |
+| `POST /api/tasks/my-pending` | 30s | 待审核任务数 |
+| `POST /api/energy/overview` | 60s | 五维能量当前值 |
+| `POST /api/daily/checkin` | 60s | 今日打卡状态(身/智/心) |
+| `POST /api/user/mascot/get` | 首次 / 手动刷新 | 获取用户已选形象 |
+
+### 状态映射表
+
+| 数据字段 | 映射宠物状态 | 触发动作 |
+|---------|------------|---------|
+| 今日任务全完成 | `happy` | 翅膀展开/甩尾巴 + 说话气泡"完成了!" |
+| 今日任务进行中(有1+未开始) | `busy` | 低头看时间 / 来回踱步 |
+| 今日任务超过2个未完成(截止后) | `worried` | 皱眉 / 叹气 |
+| 健康打卡完成 | `excited` | 转圈 / 蹦跳 |
+| 健康打卡未做(下午未打卡) | `remind` | 用爪子戳屏幕提示 |
+| 身维度能量高 | `energetic` | 活跃动作,跳跃 |
+| 身维度能量低 | `tired` | 趴下 / 打哈欠 |
+| 智维度能量高 | `curious` | 歪头思考 |
+| 所有维度都低(< 30%) | `sad` | 垂头丧气 / 沉默 |
+| 用户长时间无操作(>15min) | `sleeping` | 闭眼蜷缩,呼吸起伏 |
+| 用户正在操作键盘/鼠标 | `watching` | 抬头注视光标 |
+| 等待用户互动(如喝水提醒) | `waiting_for_interaction` | 戳屏幕 + 气泡提示 |
+| 小游戏激活(如舒尔特) | `mini-game` | 进入专注状态 + 数字/方块出现 |
+
+### 状态流转时序
+
+```
+C# 侧 Timer (30s) ──► POST /api/tasks/today
+          │
+          ▼
+StateMgr.Update(tasks, energy, checkin)
+          │
+          ▼
+  计算宠物状态
+          │
+          ▼
+window.cfcPetAPI.notifyState(state)
+          │
+          ▼
+WebView2 JS 接收 → 触发对应动画帧 → 播放音效(可选)
+```
+
+---
+
+## 四、形象配置格式(JSON Schema)
+
+### 配置文件结构
+
+```json
+{
+  "version": "1.0",
+  "type": "animal|human|custom",
+  "species": "cockatiel|british_shorthair|human|other",
+  "gender": "male|female|neutral",
+
+  "image_base_url": "./assets/pets/xibao/",
+  "default_image": "standing.png",
+
+  "actions": {
+    "idle": "wings_idle",
+    "happy": "wings_spread",
+    "busy": "head_tilt",
+    "waiting_for_interaction": "hand_gesture",
+    "success_animation": "celebrate",
+    "sleeping": "breath_loop",
+    "mini_game": "puzzle_active",
+
+    // 【新增】用户自定义状态映射
+    // 键为用户自定义状态名,值为对应的 CSS 动画类名或帧序列
+    "custom_state_1": "user_animation_1",
+    "custom_state_2": "user_animation_2"
+  },
+
+  "sounds": {
+    "click": "pip.mp3",
+    "complete": "cheer.mp3",
+    "remind": "water_please.mp3"
+  },
+
+  "parameters": {
+    "move_distance": 15,
+    "animation_speed": 1.0,
+    "interaction_timeout": 300
+  }
+}
+```
+
+### 用户自定义流程
+
+1. 用户放置 PNG 图片到 `~/.xaf-pet/images/<pet-name>/`
+2. 同目录放置 `pet-config.json`(使用上述 Schema,可在 `actions` 中添加 `custom_state_N` 键值对)
+3. 应用启动时扫描所有配置文件夹
+4. 用户在"设置 → 自定义状态"界面管理自定义状态映射
+5. C# 侧通过 `window.cfcPetAPI.setCustomState("custom_state_1", data)` 发送自定义状态
+
+**要求**:
+- 每只宠物需要一张基准 PNG(透明背景)+ 可选的动作帧图
+- CSS 动画驱动,无需多帧精灵图
+- 支持最多 10 种用户自定义状态(通过配置文件 key 如 `custom_state_1`~`custom_state_10` 定义)
+
+### C# ↔ JS Bridge 新增接口
+
+```csharp
+// C# → JS:设置自定义状态
+public void SetCustomState(string stateName, object stateData);
+```
+
+---
+
+## 五、交互规则与动作库
+
+### 核心交互动作(内置库)
+
+| 分类 | 动作名称 | 触发条件 | 说明 |
+|------|----------|----------|------|
+| **基础表情** | `nod` | 任务完成、确认打卡 | 缓慢上下点头 |
+| | `shake` | 任务被拒绝、错误 | 横向摇头 |
+| | `blink` | 默认/定时 | 3-5秒一次 |
+| | `look_around` | 进入新环境 | 左右扫视 |
+| **动物特有** | `whistle` | 用户登录、任务开始 | 鹦鹉吹口哨 |
+| | `ear_scrape` | 用户有新提醒 | 耳朵抖动 |
+| | `tail_flick` | 能量充足 | 尾巴摆动 |
+| | `paw_press` | 完成打卡确认 | 前爪按压 |
+| | `belly_rub` | 能量低 + 用户抚摸 | 腹部摩挲 |
+| **人形动作** | `wave` | 问候/离开 | 手挥动 |
+| | `stretch` | 早上唤醒 | 全身拉伸 |
+| | `clap` | 任务成功 | 双手鼓掌 |
+| | `bow` | 表达歉意/感谢 | 身体前倾 |
+| **状态动作** | `celebrate` | 任务全完成 | 跳跃 + 爱心飞出 |
+| | `sad_animation` | 任务连续失败 | 垂头、缓慢呼吸 |
+| | `worried_animation` | 任务超过期限 | 皱眉、踱步 |
+| **用户互动** | `pet` | 用户按住宠物 2s | 触摸波纹 + 依偎动作 |
+| | `feed` | 右键→喂食 | 吃掉食物动画 |
+| | `play_game` | 点击宠物进入 | 小游戏模式 |
+| | `dismiss` | 长按 3s | 宠物飞走/消失 |
+| **顽皮行为** | `steal_icon` | 随机(间隔 >10min) | 宠物靠近某个图标,假装偷走 |
+| | `eat_document` | 随机(用户保存文件后) | 宠物做出"吃东西"动作 |
+| | `hide` | 用户快速移动鼠标 | 宠物躲起来,几秒后重新出现 |
+
+### 交互流程示例:喝水提醒
+
+1. **C# 侧检测**:每 30s 轮询,发现今日未喝到水卡
+2. **状态变更**:`StateMgr` 将状态设为 `waiting_for_interaction`,原因 `drink_water`
+3. **推送通知**:`window.cfcPetAPI.notifyState({state: "waiting_for_interaction", reason: "drink_water"})`
+4. **JS 侧响应**:宠物竖起爪子 + 气泡"该喝水啦~"
+5. **用户点击确认**:`window.cfcPetAPI.onInteract("confirm")`
+6. **C# 处理**:调用 `POST /api/daily/checkin`(水分项)
+7. **反馈循环**:宠物播放 `celebrate` 动画,气泡"喝好啦!"
+
+---
+
+## 六、核心功能细节
+
+### 6.1 拖拽与置顶
+
+- **拖拽**:左键按住拖动,支持多显示器
+- **置顶**:WPF `TopMost=true`,确保始终在其他窗口上方
+- **自动隐藏**:配置 `auto_hide_when_idle=true` 后,>15min 无操作自动隐藏到托盘
+- **唤出**:恢复任意点击、任务提醒触发
+
+### 6.2 五维能量可视化
+
+宠物颜色随五维能量值动态变化:
+
+| 维度 | 颜色 | 能量高表现 | 能量低表现 |
+|------|------|-----------|-----------|
+| 身 | `#FF8C42` | 身体橙色发光 | 身体灰暗 |
+| 智 | `#6366F1` | 眼睛亮紫色 | 眼睛黯淡 |
+| 富 | `#F59E0B` | 金色光环环绕 | 光环消失 |
+| 行 | `#10B981` | 翅膀/尾巴翠绿 | 颜色褪去 |
+| 心 | `#FF6B9D` | 腮红/颈圈鲜艳 | 颜色淡出 |
+
+### 6.3 声音策略
+
+- 默认开启系统音效
+- 每个状态可配置独立音频
+- 用户可在设置中静音
+
+### 6.4 游戏模式
+
+支持的游戏类型:
+| 游戏 | 描述 | 入口 |
+|------|------|------|
+| 舒尔特方格 | 16格数字顺序点击 | 点击宠物 → "玩游戏" |
+| 记忆翻牌 | 翻牌配对 | 点击宠物 → "玩游戏" |
+| 数独 | 9×9 数独 | 点击宠物 → "玩游戏" |
+
+游戏结束后,宠物播放 `celebrate` 或 `sad_animation`。
+
+---
+
+## 七、技术栈
+
+| 层级 | 技术 | 说明 |
+|------|------|------|
+| UI 框架 | WPF (.NET 6) | 原生 Windows 桌面框架 |
+| 渲染引擎 | WebView2 | Edge 内核,支持现代 CSS/JS |
+| API 调用 | HttpClient + HttpClientFactory | 异步轮询,JWT 缓存 |
+| 动画 | CSS Animation + JavaScript | 跨状态动画驱动 |
+| 配置存储 | JSON 文件 | 用户配置 + 宠物形象 |
+| 打包 | MSIX / ClickOnce | 单文件 exe 部署 |
+
+---
+
+## 八、文件结构
+
+```
+XafPet.Desktop/
+├── src/
+│   ├── App.xaml / App.xaml.cs           # 入口
+│   ├── MainWindow.xaml / MainWindow.xaml.cs  # 主窗口
+│   ├── PetEngine/
+│   │   ├── WebViewHost.xaml             # WebView2 容器
+│   │   ├── StateManager.cs              # 状态管理
+│   │   ├── ApiClient.cs                 # CFC API 客户端
+│   │   └── PetState.cs                  # 状态模型
+│   ├── Assets/
+│   │   ├── default-pet/                 # 默认浠宝/福宝
+│   │   │   ├── standing.png
+│   │   │   └── pet-config.json
+│   │   └── sounds/                      # 音效
+│   ├── Scripts/
+│   │   ├── pet-engine.js                # 宠物渲染引擎
+│   │   └── interactions.js              # 交互逻辑
+│   └── Styles/
+│       └── animations.css               # 动画定义
+├── config/
+│   └── app.json                         # 应用配置
+└── user-data/                           # 用户数据(运行时生成)
+    └── pets/
+        └── <user-pet-name>/
+            ├── standing.png
+            └── pet-config.json
+```
+
+---
+
+## 九、部署说明
+
+- **目标平台**:Windows 10/11
+- **依赖**:WebView2 Runtime(已预装在 Win10+,旧版需单独安装)
+- **网络**:需要能访问 `http://localhost:9082`(CFC 后端)
+- **首次启动**:引导用户输入 JWT Token 或扫码登录
+- **自动更新**:可选配置 GitHub Releases 自动检查更新
+
+---
+
+## 十、验收标准
+
+- [ ] 宠物可正常显示在桌面,可拖拽移动
+- [ ] 宠物置顶功能正常工作
+- [ ] 五维能量变化时宠物颜色和状态正确反映
+- [ ] 任务完成/未完成时宠物动画触发正确
+- [ ] 喝水提醒可触发,用户点击后可完成打卡
+- [ ] 小游戏(舒尔特方格)可正常运行
+- [ ] 用户可上传自定义 PNG 形象并配置
+- [ ] 应用可正常最小化到托盘
+- [ ] 所有操作无内存泄漏,资源占用 < 50MB
+
+---
+
+## 十一、非功能性需求
+
+| 需求 | 说明 |
+|------|------|
+| **性能** | 内存占用 < 50MB,CPU < 5% |
+| **响应** | 状态刷新延迟 < 2s |
+| **兼容性** | Windows 10 (1803+)、Windows 11 |
+| **安全性** | JWT Token 加密存储,不记录密码 |
+| **可用性** | 支持中文、英文语言包 |
+| **可维护性** | 代码注释完整,支持后续扩展新游戏 |
+
+---
+
+> **下一步**:编写实现计划(writing-plans)