Browse Source

Merge remote changes and resolve conflicts

liaoxg 20 giờ trước cách đây
mục cha
commit
379184e3af
68 tập tin đã thay đổi với 5334 bổ sung và 649 xóa
  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)