Переглянути джерело

Merge origin/cfclub into cfclub

手动解决本地与远程大规模分叉(ahead 11 / behind 180)的 16 处冲突:
- BASIC 会员方向保留本地恢复:MembershipService / BasicQuotaService /
  schema.sql / DatabaseInitializer(迁移360 restoreBasicMembershipLevel +
  backfillMembershipLevelOrder 按 BASIC=2 回填)
- 指标曲线 / 首页 / 前端 api.js 采用远程(较新超集)
- 饮食打卡保留本地 shareMixin
- 清理构建产物(package.json / CHANGELOG.md / CHANGELOG-v1.0.md)中历史遗留的冲突标记

验证:cfc-backend `mvn clean compile` BUILD SUCCESS;cfc-web `vue-cli-service build` DONE。
Sisyphus Agent 23 годин тому
батько
коміт
704dbaec49
100 змінених файлів з 10602 додано та 1512 видалено
  1. 53 11
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/config/JwtInterceptor.java
  3. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
  4. 8 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/DanReportController.java
  5. 7 25
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  6. 12 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java
  7. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  8. 16 97
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSupplierController.java
  9. 18 25
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/EcomSupplierController.java
  10. 130 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/auth/WechatWebAuthController.java
  11. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java
  12. 2 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java
  13. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/TaskReminderConfigDTO.java
  14. 4 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/UpdateFamilyMemberDTO.java
  15. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java
  16. 6 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/TaskReminderConfig.java
  17. 130 0
      cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java
  18. 29 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyInvitationService.java
  19. 23 0
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyJoinRequestService.java
  20. 49 2
      cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
  21. 77 1
      cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java
  22. 3 26
      cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java
  23. 39 10
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  24. 12 3
      cfc-backend/src/main/java/com/etotem/cfc/service/SysMenuService.java
  25. 24 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskReminderConfigService.java
  26. 30 0
      cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java
  27. 104 0
      cfc-backend/src/main/java/com/etotem/cfc/service/WechatWebLoginService.java
  28. 28 9
      cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java
  29. 4 1
      cfc-backend/src/main/resources/application.yml
  30. 8 6
      cfc-backend/src/main/resources/schema.sql
  31. 46 0
      cfc-desktop-pet/.github/workflows/release.yml
  32. 3 0
      cfc-desktop-pet/.gitignore
  33. 62 0
      cfc-desktop-pet/README.md
  34. 19 0
      cfc-desktop-pet/src-tauri/.cargo/config.toml
  35. 2 0
      cfc-desktop-pet/src-tauri/.gitignore
  36. 5303 0
      cfc-desktop-pet/src-tauri/Cargo.lock
  37. 22 0
      cfc-desktop-pet/src-tauri/Cargo.toml
  38. 3 0
      cfc-desktop-pet/src-tauri/build.rs
  39. 7 0
      cfc-desktop-pet/src-tauri/capabilities/default.json
  40. 5 0
      cfc-desktop-pet/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml
  41. 4 0
      cfc-desktop-pet/src-tauri/icons/android/values/ic_launcher_background.xml
  42. BIN
      cfc-desktop-pet/src-tauri/icons/icon.icns
  43. BIN
      cfc-desktop-pet/src-tauri/icons/icon.ico
  44. 188 0
      cfc-desktop-pet/src-tauri/src/api/mod.rs
  45. 252 0
      cfc-desktop-pet/src-tauri/src/api/models.rs
  46. 10 0
      cfc-desktop-pet/src-tauri/src/app_state.rs
  47. 306 0
      cfc-desktop-pet/src-tauri/src/commands.rs
  48. 187 0
      cfc-desktop-pet/src-tauri/src/config.rs
  49. 59 0
      cfc-desktop-pet/src-tauri/src/main.rs
  50. 236 0
      cfc-desktop-pet/src-tauri/src/poller.rs
  51. 108 0
      cfc-desktop-pet/src-tauri/src/settings.rs
  52. 202 0
      cfc-desktop-pet/src-tauri/src/state.rs
  53. 71 0
      cfc-desktop-pet/src-tauri/src/token.rs
  54. 65 0
      cfc-desktop-pet/src-tauri/src/tray.rs
  55. 50 0
      cfc-desktop-pet/src-tauri/tauri.conf.json
  56. 27 0
      cfc-desktop-pet/src/DefaultPets/fubao/pet-config.json
  57. 27 0
      cfc-desktop-pet/src/DefaultPets/xibao/pet-config.json
  58. 106 0
      cfc-desktop-pet/src/animation-gen.js
  59. 46 0
      cfc-desktop-pet/src/animations.css
  60. 65 0
      cfc-desktop-pet/src/bridge.js
  61. 70 0
      cfc-desktop-pet/src/index.html
  62. 93 0
      cfc-desktop-pet/src/interactions.js
  63. 277 0
      cfc-desktop-pet/src/pet-engine.js
  64. 183 0
      cfc-desktop-pet/src/settings.html
  65. 0 0
      cfc-frontend/components/GenerationPicker.vue
  66. 3 1
      cfc-frontend/components/LoginGuideCard.vue
  67. 301 152
      cfc-frontend/components/address-picker.vue
  68. 0 0
      cfc-frontend/components/login-sheet.vue
  69. 0 0
      cfc-frontend/mixins/login-sheet.js
  70. 14 1
      cfc-frontend/pages.json
  71. 26 5
      cfc-frontend/pages/action-detail/index.vue
  72. 13 4
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  73. 3 1
      cfc-frontend/pages/article-center/article-detail.vue
  74. 7 1
      cfc-frontend/pages/article-center/index.vue
  75. 40 83
      cfc-frontend/pages/body-detail/index.vue
  76. 195 0
      cfc-frontend/pages/dan-assessment/comparison.vue
  77. 11 3
      cfc-frontend/pages/diet/food-query.vue
  78. 17 5
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  79. 8 1
      cfc-frontend/pages/discover/index.vue
  80. 17 10
      cfc-frontend/pages/energy/index.vue
  81. 2 2
      cfc-frontend/pages/family/add-member.vue
  82. 10 0
      cfc-frontend/pages/growth-main/index.vue
  83. 16 1
      cfc-frontend/pages/health-main/index.vue
  84. 302 0
      cfc-frontend/pages/health/components/TrendChart.vue
  85. 47 10
      cfc-frontend/pages/health/indicator-curve.vue
  86. 167 156
      cfc-frontend/pages/index-home/index.vue
  87. 11 9
      cfc-frontend/pages/invite/join-form.vue
  88. 14 8
      cfc-frontend/pages/invite/join.vue
  89. 26 517
      cfc-frontend/pages/login/login.vue
  90. 3 0
      cfc-frontend/pages/login/scan-login.json
  91. 172 0
      cfc-frontend/pages/login/scan-login.vue
  92. 51 13
      cfc-frontend/pages/mind-detail/index.vue
  93. 11 8
      cfc-frontend/pages/profile-extra/family-members.vue
  94. 12 6
      cfc-frontend/pages/profile-main/profile.vue
  95. 7 3
      cfc-frontend/pages/profile/index.vue
  96. 1 1
      cfc-frontend/pages/profile/service-role-apply.vue
  97. 82 241
      cfc-frontend/pages/reminder-config/index.vue
  98. 19 6
      cfc-frontend/pages/vendor/product-edit/product-edit.vue
  99. 15 5
      cfc-frontend/pages/wealth-cf/index.vue
  100. 34 43
      cfc-frontend/pages/wealth/index.vue

+ 53 - 11
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2989,6 +2989,7 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
         createFatDietRecordTable();
         createFatExerciseRecordTable();
         createFatGoalTable();
+        addProductVendorVisibleColumn();
 
         // 指标统一层迁移(356~362)
         migrateIndicatorDefinitionsColumns();
@@ -8746,6 +8747,29 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
 
         // 迁移225: articles 添加 growth_category 列(成长分类筛选,Article 实体已含字段,生产库缺列导致 Unknown column 'growth_category')
         ensureColumn("articles", "growth_category", "VARCHAR(50) DEFAULT NULL COMMENT '成长分类'");
+
+        // 迁移209: 将 ecom_supplier 合并至 supply_system(商品供应商管理 → 供应商体系管理统一)
+        // 1. 给 supply_system 加联系人字段
+        ensureColumn("supply_system", "contact_name", "VARCHAR(50) DEFAULT NULL COMMENT '联系人'");
+        ensureColumn("supply_system", "contact_phone", "VARCHAR(20) DEFAULT NULL COMMENT '联系电话'");
+        ensureColumn("supply_system", "remark", "VARCHAR(500) DEFAULT NULL COMMENT '备注'");
+        log.info("迁移209: supply_system 已添加 contact_name/contact_phone/remark 列");
+
+        // 2. 将 ecom_supplier 存量数据迁移到 supply_system(以平台管理员 id=1 为默认 admin)
+        try {
+            jdbcTemplate.update(
+                    "INSERT INTO supply_system (name, admin_id, settlement_period_days, platform_profit_rate, status, " +
+                            "contact_name, contact_phone, remark, created_at, updated_at) " +
+                            "SELECT name, 1, 30, 0, " +
+                            "CASE WHEN status = 1 THEN 'active' ELSE 'disabled' END, " +
+                            "contact_name, contact_phone, remark, created_at, COALESCE(updated_at, created_at) " +
+                            "FROM ecom_supplier WHERE NOT EXISTS (" +
+                            "  SELECT 1 FROM supply_system ss WHERE ss.name = ecom_supplier.name LIMIT 1)"
+            );
+            log.info("迁移209: ecom_supplier 存量数据已迁移至 supply_system");
+        } catch (Exception e) {
+            log.warn("迁移209: ecom_supplier 迁移失败(可能已存在): {}", e.getMessage());
+        }
     }
 
     private void runFullRegionDataMigrations() {
@@ -9768,7 +9792,7 @@ sqls.add("INSERT IGNORE INTO health_norm_reference (dimension, gender, age_min,
                 {"commerce", "productManage", "", "商品管理", "/product-manage", "el-icon-s-goods", "commerce:products,commerce:self", "0"},
                 {"commerce", "inventory", "", "库存管理", "/inventory", "el-icon-document", "inventory:self", "1"},
                 {"commerce", "orderManage", "", "订单管理", "/order-manage", "el-icon-s-order", "commerce:orders,order:self", "2"},
-                {"commerce", "ecomSupplier", "", "供应商管理", "/ecom-supplier", "el-icon-s-shop", "commerce:supplier", "3"},
+                {"commerce", "ecomSupplier", "", "供应商管理", "/supply-system", "el-icon-s-shop", "system:supply", "3"},
                 {"commerce", "productProfitRate", "", "产品利润率", "/product-profit-rate", "el-icon-data-line", "finance:profit-rate", "4"},
                 {"commerce", "coupon", "", "优惠券管理", "/coupon", "el-icon-ticket", "commerce:coupon", "5"},
                 {"commerce", "couponGrantLog", "", "发券记录", "/coupon-grant-log", "el-icon-document", "commerce:coupon", "6"},
@@ -11503,25 +11527,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());
+        }
     }
 
     /**
@@ -11813,7 +11851,6 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         }
     }
 
-    // 迁移352: 健康报告上传改为对所有登录用户免费无限
     private void removeReportUploadQuota() {
         try {
             int deletedBenefits = jdbcTemplate.update(
@@ -11846,6 +11883,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());
         }
@@ -12304,4 +12342,8 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
             log.warn("迁移358: 创建fat_goal表失败: {}", e.getMessage());
         }
     }
+
+    private void addProductVendorVisibleColumn() {
+        ensureColumn("products", "vendor_visible", "TINYINT(1) DEFAULT NULL COMMENT '是否仅供应商体系内用户可见(列表接口生效,直接分享可bypass)'");
+    }
 }

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

@@ -42,6 +42,8 @@ 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/poll",
         "/api/internal/",
         "/api/product/list",
         "/api/product/detail",

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

@@ -102,6 +102,8 @@ 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/poll",
                 "/api/admin-auth/send-code",
                 "/api/admin-auth/login",
                 "/api/admin-auth/login-by-password",
@@ -145,6 +147,8 @@ 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/poll",
                 "/api/admin-auth/send-code",
                 "/api/admin-auth/login",
                 "/api/admin-auth/login-by-password",

+ 8 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/DanReportController.java

@@ -72,4 +72,12 @@ public class DanReportController {
         String dimension = (String) body.get("dimension");
         return uploadService.listByMember(memberId, dimension);
     }
+
+    /** DAN 报告两版对比接口(设计:两次上传按 uploaded_at 对齐 parsed/edited_items 算 delta) */
+    @PostMapping("/compare")
+    public Result<?> compare(@RequestBody Map<String, Object> params) {
+        Long uploadIdA = ParamUtils.getLong(params.get("uploadIdA"));
+        Long uploadIdB = ParamUtils.getLong(params.get("uploadIdB"));
+        return uploadService.compareUploads(uploadIdA, uploadIdB);
+    }
 }

+ 7 - 25
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -899,10 +899,9 @@ public class HealthReportController {
         String originalFilename = draft.getOriginalFilename();
 
         try {
-            // LangGraph 优先:远程 OSS URL 直接传 URL(LangGraph 自行下载),本地路径传共享卷路径
+            // LangGraph 需要本地路径:经 getFilePath 下载到共享卷(LangGraph 容器可读)
             try {
-                String lgPath = (fileUrl != null && fileUrl.startsWith("http"))
-                        ? fileUrl : storageService.getFilePath(fileUrl);
+                String lgPath = storageService.getFilePath(fileUrl);
                 Map<String, Object> lgRequest = new HashMap<>();
                 lgRequest.put("file_path", lgPath);
                 lgRequest.put("user_id", userId);
@@ -1081,12 +1080,11 @@ public class HealthReportController {
 
             // 保存文件(通过 StorageService 支持本地磁盘或京东云 OSS)
             String fileUrl = storageService.storeFile(file, "health-reports");
-            boolean isRemote = fileUrl != null && fileUrl.startsWith("http");
 
-            // 尝试 LangGraph 远程解析(远程 OSS URL 直接传 URL,LangGraph 自行下载)
+            // 尝试 LangGraph 解析(LangGraph 只认本地路径,必须经 getFilePath 下载到共享卷)
             try {
                 Map<String, Object> lgRequest = new HashMap<>();
-                lgRequest.put("file_path", isRemote ? fileUrl : storageService.getFilePath(fileUrl));
+                lgRequest.put("file_path", storageService.getFilePath(fileUrl));
                 lgRequest.put("user_id", userId);
                 if (familyId != null) {
                     lgRequest.put("family_id", familyId);
@@ -2439,7 +2437,8 @@ public class HealthReportController {
         }
 
         try {
-            String absolutePath = saveUploadFileForProxy(file, userId);
+            String fileUrl = storageService.storeFile(file, "health-reports");
+            String absolutePath = storageService.getFilePath(fileUrl);
 
             // 调用 LangGraph 解析
             Map<String, Object> result = callLangGraphParse(absolutePath, userId, familyId, subjectId);
@@ -2448,6 +2447,7 @@ public class HealthReportController {
                 return Result.error("报告解析失败");
             }
 
+            result.put("fileUrl", fileUrl);
             // 保存草稿
             Long draftId = (Long) result.get("draftId");
             if (draftId != null && note != null) {
@@ -2519,24 +2519,6 @@ public class HealthReportController {
         }
     }
 
-    /**
-     * 代上传场景:保存文件(用代上传人目录)
-     */
-    private String saveUploadFileForProxy(MultipartFile file, Long proxyUserId) throws IOException {
-        String uploadDir = "uploads" + File.separator + "health-reports-proxy" + File.separator + proxyUserId;
-        File dir = new File(uploadDir);
-        if (!dir.exists()) {
-            dir.mkdirs();
-        }
-        String originalFilename = file.getOriginalFilename();
-        if (originalFilename == null) originalFilename = "report.pdf";
-        String extension = originalFilename.substring(originalFilename.lastIndexOf('.'));
-        String savedFilename = System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + extension;
-        Path filePath = Paths.get(uploadDir, savedFilename);
-        Files.copy(file.getInputStream(), filePath);
-        return uploadDir.replace("\\", "/") + "/" + savedFilename;
-    }
-
     /**
      * 调用 LangGraph 解析报告
      */

+ 12 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/IndicatorController.java

@@ -103,6 +103,18 @@ public class IndicatorController {
         return Result.success(indicatorTrendService.quickLookup(subjectId, category, page, pageSize, familyId));
     }
 
+    /** 成员已有指标时序的报告类型列表(曲线页类型筛选动态化) */
+    @PostMapping("/source-types")
+    public Result<?> sourceTypes(@RequestBody Map<String, Object> params,
+                                 @RequestAttribute("familyId") Long familyId) {
+        Long subjectId = com.etotem.cfc.util.ParamUtils.getLong(params.get("subjectId"));
+        Result<Void> check = checkSubjectBelongs(subjectId, familyId);
+        if (check != null) {
+            return check;
+        }
+        return Result.success(indicatorTrendService.listSourceTypes(subjectId));
+    }
+
     /** 指标关联查询(设计 6.4) */
     @PostMapping("/correlation/list")
     public Result<?> correlationList(@RequestBody Map<String, Object> params,

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -114,6 +114,9 @@ public class AdminArticleController {
         if (body.get("difficultyLevel") != null && !body.get("difficultyLevel").toString().trim().isEmpty()) {
             article.setDifficultyLevel(Integer.valueOf(body.get("difficultyLevel").toString()));
         }
+        if (body.get("sortOrder") != null && !body.get("sortOrder").toString().trim().isEmpty()) {
+            article.setSortOrder(Integer.valueOf(body.get("sortOrder").toString()));
+        }
         // relatedDimensions may come as array from frontend
         Object rd = body.get("relatedDimensions");
         if (rd instanceof List) {
@@ -195,6 +198,9 @@ public class AdminArticleController {
         if (body.get("difficultyLevel") != null && !body.get("difficultyLevel").toString().trim().isEmpty()) {
             article.setDifficultyLevel(Integer.valueOf(body.get("difficultyLevel").toString()));
         }
+        if (body.get("sortOrder") != null && !body.get("sortOrder").toString().trim().isEmpty()) {
+            article.setSortOrder(Integer.valueOf(body.get("sortOrder").toString()));
+        }
         Object rd = body.get("relatedDimensions");
         if (rd instanceof List) {
             article.setRelatedDimensions(String.join(",", (List<String>) rd));

+ 16 - 97
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminSupplierController.java

@@ -1,123 +1,42 @@
 package com.etotem.cfc.controller.admin;
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.EcomSupplier;
-import com.etotem.cfc.mapper.EcomSupplierMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 
-import javax.annotation.Resource;
-import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
-import com.etotem.cfc.util.SortUtil;
-import com.etotem.cfc.util.ParamUtils;
 
-@Tag(name = "管理端-电商供应商管理")
+/**
+ * @deprecated 已合并至 /api/admin/supply-system(供应商体系管理)。此接口返回 410。
+ */
+@Deprecated
+@Tag(name = "管理端-电商供应商管理(已废弃)")
 @RestController
 @RequestMapping("/api/admin/supplier")
 public class AdminSupplierController {
 
-    @Resource
-    private EcomSupplierMapper ecomSupplierMapper;
-
-    @Operation(summary = "供应商列表")
+    @Operation(summary = "【已废弃】供应商列表,请使用 /api/admin/supply-system/list")
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
-        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
-        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
-        String keyword = (String) params.get("keyword");
-
-        Page<EcomSupplier> pageParam = new Page<>(page, size);
-        LambdaQueryWrapper<EcomSupplier> wrapper = new LambdaQueryWrapper<>();
-        if (keyword != null && !keyword.isEmpty()) {
-            wrapper.like(EcomSupplier::getName, keyword);
-        }
-        wrapper.orderByDesc(EcomSupplier::getCreatedAt);
-        SortUtil.applySort(wrapper);
-        Page<EcomSupplier> result = ecomSupplierMapper.selectPage(pageParam, wrapper);
-
-        Map<String, Object> data = new HashMap<>();
-        data.put("records", result.getRecords());
-        data.put("total", result.getTotal());
-        data.put("page", page);
-        data.put("size", size);
-        return Result.success(data);
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/list");
     }
 
-    @Operation(summary = "新增供应商(兼容新旧字段契约)")
+    @Operation(summary = "【已废弃】新增供应商")
     @PostMapping("/save")
-    public Result<EcomSupplier> save(@RequestBody Map<String, Object> body) {
-        EcomSupplier supplier = new EcomSupplier();
-        applyFields(supplier, body);
-        if (supplier.getName() == null || supplier.getName().trim().isEmpty()) {
-            return Result.error("供应商名称不能为空");
-        }
-        supplier.setCreatedAt(new Date());
-        supplier.setUpdatedAt(new Date());
-        ecomSupplierMapper.insert(supplier);
-        return Result.success(supplier);
+    public Result<Map<String, Object>> save(@RequestBody Map<String, Object> body) {
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/create");
     }
 
-    @Operation(summary = "更新供应商(兼容新旧字段契约)")
+    @Operation(summary = "【已废弃】更新供应商")
     @PostMapping("/update")
-    public Result<String> update(@RequestBody Map<String, Object> body) {
-        Long id = ParamUtils.getLong(body.get("id"));
-        if (id == null) return Result.error("id不能为空");
-        EcomSupplier supplier = ecomSupplierMapper.selectById(id);
-        if (supplier == null) return Result.error("供应商不存在");
-        applyFields(supplier, body);
-        supplier.setUpdatedAt(new Date());
-        ecomSupplierMapper.updateById(supplier);
-        return Result.success("ok");
-    }
-
-    /**
-     * 字段契约兼容:前端表单提交 {phone,nickname,realName,vendorType,vendorStatus},
-     * 库表字段为 {name,contactName,contactPhone,status,remark}。新旧字段都接受,新字段优先。
-     */
-    private void applyFields(EcomSupplier supplier, Map<String, Object> body) {
-        String name = str(body, "nickname", body.get("name"));
-        if (name != null) supplier.setName(name);
-        String contactName = str(body, "realName", body.get("contactName"));
-        if (contactName != null) supplier.setContactName(contactName);
-        String contactPhone = str(body, "phone", body.get("contactPhone"));
-        if (contactPhone != null) supplier.setContactPhone(contactPhone);
-        String remark = str(body, "remark", null);
-        if (remark != null) supplier.setRemark(remark);
-        Object vendorStatus = body.get("vendorStatus") != null ? body.get("vendorStatus") : body.get("status");
-        if (vendorStatus != null) {
-            supplier.setStatus(parseStatus(vendorStatus));
-        }
-    }
-
-    private static String str(Map<String, Object> body, String key, Object fallback) {
-        Object v = body.get(key);
-        if (v == null) v = fallback;
-        return v != null ? v.toString() : null;
-    }
-
-    private static Integer parseStatus(Object v) {
-        if (v instanceof Number) return ((Number) v).intValue();
-        String s = v.toString().trim();
-        if ("active".equalsIgnoreCase(s) || "enabled".equalsIgnoreCase(s) || "1".equals(s)) return 1;
-        if ("pending".equalsIgnoreCase(s) || "disabled".equalsIgnoreCase(s) || "0".equals(s)) return 0;
-        try {
-            return Integer.valueOf(s);
-        } catch (NumberFormatException e) {
-            return 1;
-        }
+    public Result<Map<String, Object>> update(@RequestBody Map<String, Object> body) {
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/update");
     }
 
-    @Operation(summary = "删除供应商")
+    @Operation(summary = "【已废弃】删除供应商")
     @PostMapping("/delete")
-    public Result<String> delete(@RequestBody Map<String, Object> params) {
-        Long id = ParamUtils.getLong(params.get("id"));
-        if (id == null) return Result.error("id不能为空");
-        ecomSupplierMapper.deleteById(id);
-        return Result.success("ok");
+    public Result<Map<String, Object>> delete(@RequestBody Map<String, Object> params) {
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/toggle-status");
     }
 }

+ 18 - 25
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/EcomSupplierController.java

@@ -1,56 +1,49 @@
 package com.etotem.cfc.controller.admin;
 
 import com.etotem.cfc.common.Result;
-import com.etotem.cfc.entity.EcomSupplier;
-import com.etotem.cfc.service.EcomSupplierService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 
-import javax.annotation.Resource;
 import java.util.List;
 import java.util.Map;
-import com.etotem.cfc.util.ParamUtils;
 
-@Tag(name = "管理端-电商供应商")
+/**
+ * @deprecated 已合并至 /api/admin/supply-system(供应商体系管理)。此接口返回 410。
+ */
+@Deprecated
+@Tag(name = "管理端-电商供应商(已废弃)")
 @RestController
 @RequestMapping("/api/admin/ecom-supplier")
 public class EcomSupplierController {
 
-    @Resource
-    private EcomSupplierService supplierService;
-
-    @Operation(summary = "供应商列表")
+    @Operation(summary = "【已废弃】供应商列表,请使用 /api/admin/supply-system/list")
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params) {
-        int page = params.get("page") != null ? ((Number) params.get("page")).intValue() : 1;
-        int size = params.get("size") != null ? ((Number) params.get("size")).intValue() : 20;
-        String keyword = (String) params.get("keyword");
-        return supplierService.list(page, size, keyword);
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/list");
     }
 
-    @Operation(summary = "所有供应商(下拉选择用)")
+    @Operation(summary = "【已废弃】所有供应商(下拉选择用)")
     @PostMapping("/all")
-    public Result<List<EcomSupplier>> all() {
-        return supplierService.all();
+    public Result<List<Object>> all() {
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/list-all");
     }
 
-    @Operation(summary = "创建供应商")
+    @Operation(summary = "【已废弃】创建供应商")
     @PostMapping("/save")
-    public Result<String> save(@RequestBody EcomSupplier supplier) {
-        return supplierService.save(supplier);
+    public Result<String> save(@RequestBody Object supplier) {
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/create");
     }
 
-    @Operation(summary = "更新供应商")
+    @Operation(summary = "【已废弃】更新供应商")
     @PostMapping("/update")
-    public Result<String> update(@RequestBody EcomSupplier supplier) {
-        return supplierService.update(supplier);
+    public Result<String> update(@RequestBody Object supplier) {
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/update");
     }
 
-    @Operation(summary = "删除供应商")
+    @Operation(summary = "【已废弃】删除供应商")
     @PostMapping("/delete")
     public Result<String> delete(@RequestBody Map<String, Object> params) {
-        Long id = ParamUtils.getLong(params.get("id"));
-        return supplierService.delete(id);
+        return Result.error(410, "该接口已废弃,请使用 /api/admin/supply-system/toggle-status");
     }
 }

+ 130 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/auth/WechatWebAuthController.java

@@ -0,0 +1,130 @@
+package com.etotem.cfc.controller.auth;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.LoginResultDTO;
+import com.etotem.cfc.service.UserService;
+import com.etotem.cfc.service.WechatWebLoginService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 微信「小程序码」扫码登录(桌面端 SSO)。
+ *
+ * <p>流程:桌面端 POST /qr 拿 state+小程序码图片 → 用户用微信扫码打开小程序落地页
+ * (从 scene 取 state)→ 小程序(已登录,带小程序 JWT)POST /scan 确认 →
+ * 服务端写 state=U{userId} → 桌面端轮询 POST /poll 取 JWT。
+ *
+ * <p>全部为 POST:/qr、/poll 公开(桌面端尚未持有令牌),/scan 需小程序 JWT
+ * (扫码者即小程序已登录用户,据此完成与小程序账号的 SSO)。
+ */
+@Tag(name = "微信小程序扫码登录", description = "桌面端小程序码扫码登录,与小程序账号 SSO")
+@RestController
+@Slf4j
+@RequestMapping("/api/auth/wechat-web")
+public class WechatWebAuthController {
+
+    @Resource
+    private WechatWebLoginService wechatWebLoginService;
+
+    @Resource
+    private UserService userService;
+
+    @Operation(summary = "获取扫码登录小程序码")
+    @PostMapping("/qr")
+    public Result<Map<String, Object>> qr() {
+        String state = UUID.randomUUID().toString().replace("-", "");
+        wechatWebLoginService.putState(state, "P");
+        Map<String, Object> data = new HashMap<>();
+        data.put("state", state);
+        try {
+            String base64 = wechatWebLoginService.generateQrImage(state);
+            data.put("qrImage", base64 == null || base64.isEmpty() ? "" : "data:image/png;base64," + base64);
+        } catch (RuntimeException e) {
+            log.error("生成登录小程序码失败: state={}", state, e);
+            wechatWebLoginService.removeState(state);
+            return Result.error("生成二维码失败:" + e.getMessage());
+        }
+        return Result.success(data);
+    }
+
+    @Operation(summary = "小程序扫码确认登录(小程序端调用,携带小程序 JWT)")
+    @PostMapping("/scan")
+    public Result<Map<String, Object>> scan(@RequestBody Map<String, String> body,
+                                            @RequestAttribute("userId") Long userId) {
+        String state = body != null ? body.get("state") : null;
+        if (state == null || state.trim().isEmpty()) {
+            return Result.error("缺少 state");
+        }
+        Object stored = wechatWebLoginService.getState(state);
+        if (stored == null) {
+            return Result.error("二维码已过期,请重新获取");
+        }
+        if (!"P".equals(stored.toString())) {
+            return Result.error("二维码已被使用或已过期");
+        }
+        if (userId == null) {
+            return Result.error("请先登录小程序");
+        }
+        wechatWebLoginService.putState(state, "U" + userId);
+        log.info("小程序扫码确认登录: state={}, userId={}", state, userId);
+        Map<String, Object> data = new HashMap<>();
+        data.put("status", "ok");
+        return Result.success(data);
+    }
+
+    @Operation(summary = "轮询扫码登录结果")
+    @PostMapping("/poll")
+    public Result<Map<String, Object>> poll(@RequestBody Map<String, String> body) {
+        String state = body != null ? body.get("state") : null;
+        Map<String, Object> data = new HashMap<>();
+        if (state == null || state.trim().isEmpty()) {
+            return Result.error("缺少 state");
+        }
+        Object stored = wechatWebLoginService.getState(state);
+        if (stored == null) {
+            data.put("status", "expired");
+            return Result.success(data);
+        }
+        String value = stored.toString();
+        if ("P".equals(value)) {
+            data.put("status", "pending");
+            return Result.success(data);
+        }
+        if ("N".equals(value)) {
+            wechatWebLoginService.removeState(state);
+            data.put("status", "notfound");
+            return Result.success(data);
+        }
+        if (value.startsWith("U")) {
+            wechatWebLoginService.removeState(state);
+            Long userId = Long.valueOf(value.substring(1));
+            try {
+                LoginResultDTO login = userService.ssoLogin(userId);
+                data.put("status", "success");
+                data.put("token", login.getToken());
+                data.put("userId", login.getUserId());
+                data.put("role", login.getRole());
+                data.put("nickname", login.getNickname());
+            } catch (RuntimeException e) {
+                log.warn("小程序扫码换取 JWT 失败: state={}, error={}", state, e.getMessage());
+                data.put("status", "error");
+                data.put("message", e.getMessage() != null ? e.getMessage() : "登录失败");
+            }
+            return Result.success(data);
+        }
+        data.put("status", "error");
+        data.put("message", "未知状态");
+        return Result.success(data);
+    }
+}

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/FamilyMemberVO.java

@@ -64,6 +64,9 @@ public class FamilyMemberVO {
     /** 是否配偶 */
     private Boolean isSpouse;
 
+    /** 出生日期(yyyy-MM-dd 字符串,供前端编辑回显) */
+    private String birthday;
+
     /** 信任度分数 */
     private java.math.BigDecimal trustScore;
 

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -35,6 +35,7 @@ public class ProductDTO {
     private List<String> visibleRoles;     // 可见角色列表,NULL=全部角色可见(已废弃,保留兼容)
     private String minVisibleLevel;        // 最低可见会员级别编码,NULL=全部可见
     private String minDiscountLevel;       // 享受会员价所需的最低会员级别,NULL=所有会员均可
+    private Boolean vendorVisible;         // 是否仅供应商体系内用户可见(列表接口生效)
     private List<String> imageList;
     private Long energyDimensionId;
     private Integer effectivePpoint;     // 有效P点(基点,如1000=10%)
@@ -125,6 +126,7 @@ public class ProductDTO {
         }
         d.minVisibleLevel = p.getMinVisibleLevel();
         d.minDiscountLevel = p.getMinDiscountLevel();
+        d.vendorVisible = p.getVendorVisible();
         d.createdAt = p.getCreatedAt();
         d.updatedAt = p.getUpdatedAt();
         // 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)

+ 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

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

@@ -14,4 +14,8 @@ public class UpdateFamilyMemberDTO {
     private String relationshipType;
     private String roleOverride;
     private Integer showToFamily;
+    /** 辈分等级字符串(仅管理员可更新):parent/child/grandparent/peer/great_grandparent 等 */
+    private String generationLevel;
+    /** 同辈类型(generationLevel=peer 时填写):sibling=兄弟姐妹,spouse=配偶 */
+    private String peerType;
 }

+ 6 - 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; // 会员价(分)
@@ -58,6 +60,9 @@ public class Product implements Serializable {
     /** 享受会员价所需的最低会员级别编码(NULL=所有会员均可;FAMILY/PREMIUM等) */
     private String minDiscountLevel;
 
+    /** 是否仅供应商体系内用户可见(在列表接口生效,详情页不受影响,直接分享可 bypass) */
+    private Boolean vendorVisible;
+
     // 佣金系统 - 平台利润率(千分比)
     private Integer profitRate;
 
@@ -103,6 +108,7 @@ public class Product implements Serializable {
     public String getVisibleRoles() { return visibleRoles; }
     public String getMinVisibleLevel() { return minVisibleLevel; }
     public String getMinDiscountLevel() { return minDiscountLevel; }
+    public Boolean getVendorVisible() { return vendorVisible; }
     public Date getCreatedAt() { return createdAt; }
     public Date getUpdatedAt() { return updatedAt; }
 }

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

+ 130 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java

@@ -26,10 +26,13 @@ import java.io.FileInputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.text.SimpleDateFormat;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 import com.etotem.cfc.util.SortUtil;
 
 @Service
@@ -617,4 +620,131 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
         }
         return Result.success(vo);
     }
+
+    /** DAN 报告两版对比:按 uploaded_at 先后排序,较早的为基准。
+     * 返回 parsed/edited items 差异列表(数值 delta + 新增/删除标记)。
+     */
+    public Result<Map<String, Object>> compareUploads(Long uploadIdA, Long uploadIdB) {
+        if (uploadIdA == null || uploadIdB == null) {
+            return Result.error("缺少 uploadIdA 或 uploadIdB");
+        }
+        DanReportUpload uploadA = uploadMapper.selectById(uploadIdA);
+        DanReportUpload uploadB = uploadMapper.selectById(uploadIdB);
+        if (uploadA == null || uploadB == null) {
+            return Result.error("上传记录不存在");
+        }
+        if (!uploadA.getFamilyMemberId().equals(uploadB.getFamilyMemberId())) {
+            return Result.error("两次上传必须属于同一成员");
+        }
+        // 以 uploaded_at 先后顺序排序:较早的为基准
+        DanReportUpload first = uploadA.getUploadedAt() != null && uploadB.getUploadedAt() != null
+                && uploadA.getUploadedAt().before(uploadB.getUploadedAt()) ? uploadA : uploadB;
+        DanReportUpload second = first == uploadA ? uploadB : uploadA;
+        // 使用 edited_* 优先,否则 parsed_*
+        String firstItems = first.getEditedItems() != null ? first.getEditedItems() : first.getParsedItems();
+        String firstSummary = first.getEditedSummary() != null ? first.getEditedSummary() : first.getParsedSummary();
+        String secondItems = second.getEditedItems() != null ? second.getEditedItems() : second.getParsedItems();
+        String secondSummary = second.getEditedSummary() != null ? second.getEditedSummary() : second.getParsedSummary();
+        List<Map<String, Object>> diff = diffDeltas(firstItems, secondItems, first, second);
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("uploadIdA", uploadA.getId());
+        result.put("uploadIdB", uploadB.getId());
+        result.put("firstUploadId", first.getId());
+        result.put("secondUploadId", second.getId());
+        result.put("orderBy", "uploaded_at_asc");
+        result.put("deltas", diff);
+        result.put("firstSummary", firstSummary);
+        result.put("secondSummary", secondSummary);
+        return Result.success(result);
+    }
+
+    /** 指定两次上传的 items 差异:以 code 对齐,数值算 delta,单边项标 added/removed */
+    private List<Map<String, Object>> diffDeltas(String itemsJson1, String itemsJson2,
+                                                 DanReportUpload first, DanReportUpload second) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (itemsJson1 == null || itemsJson2 == null) {
+            return result;
+        }
+        List<Map<String, Object>> list1, list2;
+        try {
+            list1 = objectMapper.readValue(itemsJson1,
+                    new TypeReference<List<Map<String, Object>>>() {});
+            list2 = objectMapper.readValue(itemsJson2,
+                    new TypeReference<List<Map<String, Object>>>() {});
+        } catch (Exception e) {
+            return result;
+        }
+        if (list1.isEmpty() || list2.isEmpty()) {
+            return result;
+        }
+        // 以 code 为键建立映射(code 唯一标识一项指标)
+        Map<String, Map<String, Object>> map1 = new LinkedHashMap<>();
+        for (Map<String, Object> item : list1) {
+            String code = item.get("code") != null ? String.valueOf(item.get("code")) : null;
+            if (code != null) map1.put(code, item);
+        }
+        Map<String, Map<String, Object>> map2 = new LinkedHashMap<>();
+        for (Map<String, Object> item : list2) {
+            String code = item.get("code") != null ? String.valueOf(item.get("code")) : null;
+            if (code != null) map2.put(code, item);
+        }
+        // 合并所有 codes(保持先出现顺序 + 新增 codes)
+        LinkedHashSet<String> codes = new LinkedHashSet<>();
+        for (Map<String, Object> item : list1) {
+            String code = item.get("code") != null ? String.valueOf(item.get("code")) : null;
+            if (code != null) codes.add(code);
+        }
+        for (Map<String, Object> item : list2) {
+            String code = item.get("code") != null ? String.valueOf(item.get("code")) : null;
+            if (code != null) codes.add(code);
+        }
+        for (String code : codes) {
+            Map<String, Object> m1 = map1.get(code);
+            Map<String, Object> m2 = map2.get(code);
+            if (m1 != null && m2 != null) {
+                Map<String, Object> delta = new LinkedHashMap<>();
+                delta.put("status", "modified");
+                delta.put("code", code);
+                delta.put("name1", m1.get("name"));
+                delta.put("name2", m2.get("name"));
+                delta.put("category1", m1.get("category"));
+                delta.put("category2", m2.get("category"));
+                Object v1 = m1.get("value");
+                Object v2 = m2.get("value");
+                if (v1 instanceof Number && v2 instanceof Number) {
+                    double d1 = ((Number) v1).doubleValue();
+                    double d2 = ((Number) v2).doubleValue();
+                    delta.put("value1", v1);
+                    delta.put("value2", v2);
+                    delta.put("valueDelta", Math.round((d2 - d1) * 10.0) / 10.0);
+                    delta.put("change", Math.abs(d2 - d1) > 0.001 ? "increase" : "no_change");
+                } else {
+                    delta.put("value1", v1);
+                    delta.put("value2", v2);
+                    delta.put("valueDelta", null);
+                    delta.put("change", "text_changed");
+                }
+                result.add(delta);
+            } else if (m1 == null) {
+                Map<String, Object> added = new LinkedHashMap<>();
+                added.put("status", "added");
+                added.put("code", code);
+                added.put("name2", m2.get("name"));
+                added.put("value2", m2.get("value"));
+                added.put("category2", m2.get("category"));
+                added.put("from", second.getUploadedAt() != null ? second.getUploadedAt().toString() : "unknown");
+                result.add(added);
+            } else {
+                Map<String, Object> removed = new LinkedHashMap<>();
+                removed.put("status", "removed");
+                removed.put("code", code);
+                removed.put("name1", m1.get("name"));
+                removed.put("value1", m1.get("value"));
+                removed.put("category1", m1.get("category"));
+                removed.put("from", first.getUploadedAt() != null ? first.getUploadedAt().toString() : "unknown");
+                result.add(removed);
+            }
+        }
+        return result;
+    }
 }

+ 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);
+                }
             }
         }
 

+ 49 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java

@@ -261,8 +261,9 @@ public class FamilyMemberService {
             vo.setShowToFamily(fm.getShowToFamily());
             if (fm.getBirthday() != null) {
                 vo.setAge(calculateAge(fm.getBirthday()));
+                vo.setBirthday(formatDate(fm.getBirthday()));
             }
-            // 计算视角相关的关系标签(优先使用关系表)
+            // 计算视角相关的关系标签
             if (viewerMember != null) {
                 Integer genValue = null;
                 if (familyRelationshipService != null && viewerMember.getId() != null) {
@@ -674,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 值清零丢弃(留在原家庭)
@@ -985,6 +1020,7 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         vo.setShowToFamily(member.getShowToFamily());
         if (member.getBirthday() != null) {
             vo.setAge(calculateAge(member.getBirthday()));
+            vo.setBirthday(formatDate(member.getBirthday()));
         }
         // 设置 isSwitchable:仅无 openid 的不可登录成员可被切换(可登录成员不允许切换视角)
         if (member.getUserId() != null) {
@@ -1011,6 +1047,17 @@ vo.setIsSelf(viewerMember != null && viewerMember.getId().equals(fm.getId()));
         return Math.max(0, age);
     }
 
+    /** 格式化为 yyyy-MM-dd(前端编辑回显用),返回 null 时不修改 */
+    private String formatDate(java.util.Date d) {
+        if (d == null) return null;
+        try {
+            java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("yyyy-MM-dd");
+            return sdf.format(d);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
     /**
      * 计算成员的实际角色(effective role)
      * 优先使用年龄判定(birthday → age < childThreshold → child),

+ 77 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/IndicatorTrendService.java

@@ -107,6 +107,14 @@ public class IndicatorTrendService {
         }
         List<IndicatorValue> rows = valueMapper.selectList(qw.orderByAsc(IndicatorValue::getReportDate));
 
+        // 该成员(+报告类型)全部观测日期全集:用于计算"相邻两次对比中间缺失的报告期数"
+        java.util.List<java.sql.Date> allDates = rows.stream()
+                .map(IndicatorValue::getReportDate)
+                .filter(d -> d != null)
+                .distinct()
+                .sorted()
+                .collect(Collectors.toList());
+
         // 按 definitionId 分组
         Map<Long, List<IndicatorValue>> byDef = rows.stream()
                 .collect(Collectors.groupingBy(IndicatorValue::getDefinitionId));
@@ -124,6 +132,23 @@ public class IndicatorTrendService {
             if ("adjacent".equals(scope)) {
                 before = sorted.get(sorted.size() - 2);
             }
+            // skipGaps:before/after 之间存在报告日期、但该指标缺失的期数(跨期跳比标记)
+            // before 在 adjacent 分支被重新赋值,lambda 需 final 副本
+            long skipGaps = 0;
+            if ("adjacent".equals(scope)
+                    && before.getReportDate() != null && after.getReportDate() != null) {
+                final IndicatorValue beforeF = before;
+                final IndicatorValue afterF = after;
+                long reportsBetween = allDates.stream()
+                        .filter(d -> d.after(beforeF.getReportDate()) && d.before(afterF.getReportDate()))
+                        .count();
+                long obsBetween = sorted.stream()
+                        .filter(v -> v.getReportDate() != null
+                                && v.getReportDate().after(beforeF.getReportDate())
+                                && v.getReportDate().before(afterF.getReportDate()))
+                        .count();
+                skipGaps = Math.max(0, reportsBetween - obsBetween);
+            }
             IndicatorDefinition def = defCache.computeIfAbsent(e.getKey(),
                     id -> definitionMapper.selectById(id));
             String defCategory = def != null ? def.getCategory() : null;
@@ -136,9 +161,10 @@ public class IndicatorTrendService {
                 continue;
             }
             BigDecimal delta = av.subtract(bv);
+            // 基数为 0 时百分比无意义(如 0 → 0.5 显示 +0.5 而非误导性的 0%),置 null 由前端降级为只显示 Δ
             BigDecimal deltaPct = bv.compareTo(BigDecimal.ZERO) != 0
                     ? delta.multiply(BigDecimal.valueOf(100)).divide(bv, 1, RoundingMode.HALF_UP)
-                    : BigDecimal.ZERO;
+                    : null;
             String movement = "flat";
             if (delta.compareTo(BigDecimal.ZERO) > 0) movement = "up";
             else if (delta.compareTo(BigDecimal.ZERO) < 0) movement = "down";
@@ -165,6 +191,7 @@ public class IndicatorTrendService {
             item.put("after", afterMap);
             item.put("delta", delta);
             item.put("deltaPct", deltaPct);
+            item.put("skipGaps", skipGaps);
             item.put("movement", movement);
             item.put("trendLabel", trendLabel);
             items.add(item);
@@ -194,6 +221,55 @@ public class IndicatorTrendService {
         return data;
     }
 
+    /** 该成员已有指标时序的报告类型(indicator_values.source_type 去重,曲线页类型筛选动态来源) */
+    public List<Map<String, Object>> listSourceTypes(Long subjectId) {
+        List<Map<String, Object>> items = new ArrayList<>();
+        if (subjectId == null) {
+            return items;
+        }
+        List<IndicatorValue> rows = valueMapper.selectList(new LambdaQueryWrapper<IndicatorValue>()
+                .eq(IndicatorValue::getSubjectId, subjectId)
+                .isNotNull(IndicatorValue::getSourceType)
+                .isNotNull(IndicatorValue::getReportDate)
+                .select(IndicatorValue::getSourceType));
+        Set<String> types = new LinkedHashSet<>();
+        for (IndicatorValue v : rows) {
+            String t = v.getSourceType();
+            if (t != null && !t.trim().isEmpty()) {
+                types.add(t.trim());
+            }
+        }
+        for (String t : types) {
+            Map<String, Object> m = new LinkedHashMap<>();
+            m.put("sourceType", t);
+            m.put("label", sourceTypeLabel(t));
+            items.add(m);
+        }
+        return items;
+    }
+
+    private String sourceTypeLabel(String t) {
+        if (t == null) {
+            return t;
+        }
+        if (t.startsWith("gut_")) {
+            return "肠道菌群";
+        }
+        if ("food_recommend".equals(t)) {
+            return "食材推荐";
+        }
+        if (t.startsWith("dan_")) {
+            return "DAN 测评";
+        }
+        if ("physical_exam".equals(t)) {
+            return "体检";
+        }
+        if ("tongue".equals(t)) {
+            return "舌诊";
+        }
+        return t;
+    }
+
     /** 指标速查(设计 6.3):分类分页 + 五维/七维评分小节 */
     public Map<String, Object> quickLookup(Long subjectId, String category, Integer page,
                                            Integer pageSize, Long familyId) {

+ 3 - 26
cfc-backend/src/main/java/com/etotem/cfc/service/MembershipService.java

@@ -243,8 +243,6 @@ public class MembershipService implements MembershipServiceInterface {
                     || "referral_commission".equals(feature);
         }
 
-
-        // BASIC 体验会员可用功能(9.9元/月,限次配额型)
         if ("BASIC".equals(level)) {
             return "free_activities".equals(feature)
                     || "free_courses".equals(feature)
@@ -258,8 +256,6 @@ public class MembershipService implements MembershipServiceInterface {
                     || "task_template_lite".equals(feature)
                     || "referral_commission".equals(feature);
         }
-
-
         if ("FAMILY".equals(level)) {
             return "free_activities".equals(feature)
                     || "free_courses".equals(feature)
@@ -836,16 +832,6 @@ public class MembershipService implements MembershipServiceInterface {
             Long buyerFamilyId = (buyer != null) ? buyer.getFamilyId() : null;
             cfCommissionService.settleReferrerOnly(order.getId(), "membership", adminUserId, buyerFamilyId, order.getAmount());
 
-            // 如果是 BASIC 等级,初始化月度配额
-            if ("BASIC".equals(order.getLevelCode())) {
-                try {
-                    activationQuotaService.activateBasicQuota(adminUserId, order.getFamilyId());
-                    log.info("BASIC配额初始化完成: userId={}, familyId={}", adminUserId, order.getFamilyId());
-                } catch (Exception e) {
-                    log.error("BASIC配额初始化失败: userId={}", adminUserId, e);
-                }
-            }
-
             log.info("会员开通完成: orderNo={}, levelCode={}, amount={}, fromLevel={}",
                     order.getOrderNo(), order.getLevelCode(), order.getAmount(), fromLevel);
             return true;
@@ -1306,7 +1292,7 @@ public class MembershipService implements MembershipServiceInterface {
 
         // 个人会员身份(含有效期校验)
         String personal = user.getMemberLevel() != null ? user.getMemberLevel() : "FREE";
-        if (("FAMILY".equals(personal) || "BASIC".equals(personal)) && user.getMemberExpireTime() != null) {
+        if ("FAMILY".equals(personal) && user.getMemberExpireTime() != null) {
             if (user.getMemberExpireTime().before(new Date())) {
                 personal = "FREE";
             }
@@ -1423,19 +1409,10 @@ public class MembershipService implements MembershipServiceInterface {
     }
 
     /**
-     * 查询当前有效的 BASIC 会员用户ID列表(到期提醒用)
+     * 查询当前有效的会员用户ID列表(已废弃:BASIC体验版会员级别已删除)
      */
     public List<Long> listActiveBasicMemberUserIds() {
-        Date now = new Date();
-        List<User> basicUsers = userMapper.selectList(
-                new LambdaQueryWrapper<User>()
-                        .eq(User::getMemberLevel, "BASIC")
-                        .isNotNull(User::getMemberExpireTime)
-                        .gt(User::getMemberExpireTime, now));
-        return basicUsers != null ? basicUsers.stream()
-                .map(User::getId)
-                .distinct()
-                .collect(Collectors.toList()) : Collections.emptyList();
+        return Collections.emptyList();
     }
 
     /**

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

@@ -110,6 +110,12 @@ wrapper.in(Product::getId, productIds)
                 }
             });
         }
+        // 供应商可见过滤:仅对非供应商体系用户生效,供应商体系内用户可查看全部商品
+        boolean isVendor = isVendorUser(userId, role);
+        if (!isVendor && !isGuest(userId)) {
+            // 非供应商用户:只展示 vendorVisible=false 或 NULL 的商品
+            wrapper.and(w -> w.isNull(Product::getVendorVisible).or().eq(Product::getVendorVisible, false));
+        }
         if (query.getProductType() != null && !query.getProductType().isEmpty()) {
             wrapper.eq(Product::getProductType, query.getProductType());
         }
@@ -229,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());
@@ -250,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());
@@ -511,6 +524,22 @@ wrapper.in(Product::getId, productIds)
         return false;
     }
 
+    /** 判断是否为游客(未登录) */
+    private boolean isGuest(Long userId) {
+        return userId == null;
+    }
+
+    /** 判断用户是否为供应商体系内用户(审核通过的服务商或 teacher 角色) */
+    private boolean isVendorUser(Long userId, String role) {
+        if (userId == null) return false;
+        if ("teacher".equals(role)) return true;
+        User vendor = userMapper.selectById(userId);
+        if (vendor != null && "approved".equals(vendor.getVendorStatus())) {
+            return true;
+        }
+        return false;
+    }
+
     /**
      * 管理员更新商品(含测评扩展信息)
      */

+ 12 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/SysMenuService.java

@@ -137,17 +137,26 @@ public class SysMenuService extends ServiceImpl<SysMenuMapper, SysMenu> {
         Set<Long> included = new HashSet<>();
         for (SysMenu m : all) {
             if (authed.contains(m.getId()) && Integer.valueOf(1).equals(m.getVisible())) {
-                // 向上补全父级链
+                // 向上补全父级链,要求路径上所有祖先节点均 visible=1
+                boolean chainVisible = true;
                 Long cur = m.getId();
-                while (cur != null && !included.contains(cur) && idSet.contains(cur)) {
-                    included.add(cur);
+                List<Long> chainNodes = new ArrayList<>();
+                while (cur != null && idSet.contains(cur)) {
                     SysMenu node = byId.get(cur);
+                    if (!Integer.valueOf(1).equals(node.getVisible())) {
+                        chainVisible = false;
+                        break;
+                    }
+                    chainNodes.add(cur);
                     Long pid = node.getParentId() == null ? 0L : node.getParentId();
                     if (pid == 0L || !idSet.contains(pid)) {
                         break;
                     }
                     cur = pid;
                 }
+                if (chainVisible) {
+                    included.addAll(chainNodes);
+                }
             }
         }
 

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

+ 104 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatWebLoginService.java

@@ -0,0 +1,104 @@
+package com.etotem.cfc.service;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.time.Duration;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 桌面端「小程序码」扫码登录。
+ *
+ * <p>流程:桌面端 POST /qr 拿 state+小程序码图片 → 用户用微信扫码打开小程序落地页
+ * (落地页从 scene 取出 state)→ 小程序(已登录,带小程序 JWT)POST /scan 确认
+ * → 服务端写 state=U{userId} → 桌面端轮询 POST /poll 取 JWT。
+ *
+ * <p>采用小程序 appid/secret + {@code getwxacodeunlimit},无需「网站应用」凭据,
+ * 也不产生任何 GET 回调;扫码者身份即小程序已登录用户,天然与小程序账号 SSO。
+ */
+@Service
+public class WechatWebLoginService {
+
+    private static final Logger log = LoggerFactory.getLogger(WechatWebLoginService.class);
+
+    private static final String STATE_KEY_PREFIX = "wechat:web:login:";
+    /** state 有效期(秒),与二维码展示时长一致 */
+    private static final long STATE_TTL_SECONDS = 300;
+
+    @Value("${wechat.test-mode:false}")
+    private boolean testMode;
+
+    /** 扫码落地的小程序页面路径(须已在 pages.json 注册) */
+    @Value("${wechat.wxacode-login-page:pages/login/scan-login}")
+    private String scanPage;
+
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Resource
+    private WechatService wechatService;
+
+    /** Redis 不可用时的内存兜底:state -> [value, 写入时间(ms)] */
+    private final Map<String, String[]> memory = new ConcurrentHashMap<>();
+
+    /**
+     * 生成扫码登录用的小程序码(PNG 的 base64 编码,不含 data URI 前缀)。
+     * scene 直接放入 state(32 位十六进制,符合微信「最大 32 个可见字符」限制)。
+     * 测试模式返回空串(无真实小程序码,桌面端展示提示即可)。
+     */
+    public String generateQrImage(String state) {
+        if (testMode) {
+            log.info("测试模式:跳过小程序码生成(state={})", state);
+            return "";
+        }
+        return wechatService.generateWxacode(state, scanPage);
+    }
+
+    /** 写入扫码状态:P=待扫码 / U{userId}=已确认 / N=未绑定 */
+    public void putState(String state, String value) {
+        try {
+            redisTemplate.opsForValue().set(STATE_KEY_PREFIX + state, value,
+                    Duration.ofSeconds(STATE_TTL_SECONDS));
+        } catch (Exception e) {
+            log.warn("写入 state 到 Redis 失败,改用内存兜底: {}", e.getMessage());
+            memory.put(state, new String[]{value, String.valueOf(System.currentTimeMillis())});
+        }
+    }
+
+    /** 读取扫码状态;不存在或已过期返回 null */
+    public Object getState(String state) {
+        try {
+            Object v = redisTemplate.opsForValue().get(STATE_KEY_PREFIX + state);
+            if (v != null) {
+                return v;
+            }
+        } catch (Exception e) {
+            log.warn("读取 state 从 Redis 失败,改用内存兜底: {}", e.getMessage());
+        }
+        String[] entry = memory.get(state);
+        if (entry == null) {
+            return null;
+        }
+        long ts = Long.parseLong(entry[1]);
+        if (System.currentTimeMillis() - ts > STATE_TTL_SECONDS * 1000) {
+            memory.remove(state);
+            return null;
+        }
+        return entry[0];
+    }
+
+    /** 清除扫码状态(登录成功后调用,防止重放) */
+    public void removeState(String state) {
+        try {
+            redisTemplate.delete(STATE_KEY_PREFIX + state);
+        } catch (Exception e) {
+            log.warn("删除 state 从 Redis 失败: {}", e.getMessage());
+        }
+        memory.remove(state);
+    }
+}

+ 28 - 9
cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java

@@ -25,9 +25,11 @@ import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
 import java.time.LocalDate;
@@ -61,6 +63,13 @@ public class JdCloudStorageService implements StorageService {
     @Value("${jdcloud.region:cn-north-1}")
     private String region;
 
+    /**
+     * 本地共享目录。京东云对象在供本地进程(如 cfc-langgraph 容器)按路径读取时,
+     * 会被下载到该目录下的缓存子目录,确保 LangGraph 能读到文件。
+     */
+    @Value("${upload.base-dir:/data/cfc-uploads}")
+    private String uploadBaseDir;
+
     @Resource
     private FileRecordMapper fileRecordMapper;
 
@@ -153,25 +162,35 @@ public class JdCloudStorageService implements StorageService {
 
     @Override
     public String getFilePath(String filename) {
-        // 京东云文件不在本地磁盘,下载到临时目录供本地解析使用
+        // 京东云文件不在本地磁盘:下载到共享卷 upload.base-dir 下的缓存目录,
+        // 供本地进程(含 cfc-langgraph 容器)按本地路径读取;缓存目录需可被其它用户遍历/读取。
         try {
             String key = extractKey(filename);
-            S3Object object = s3Client.getObject(bucket, key);
-
-            Path tempDir = Paths.get(System.getProperty("java.io.tmpdir"), "cfc-jdcloud");
-            Files.createDirectories(tempDir);
-            String localName = key.substring(key.lastIndexOf('/') + 1);
-            Path tempFile = tempDir.resolve(UUID.randomUUID().toString() + "-" + localName);
+            Path cacheDir = Paths.get(uploadBaseDir, ".jdcloud-cache").toAbsolutePath().normalize();
+            Files.createDirectories(cacheDir);
+            cacheDir.toFile().setReadable(true, false);
+            cacheDir.toFile().setExecutable(true, false);
+
+            String localName = sha256(key.getBytes(StandardCharsets.UTF_8))
+                    + "-" + key.substring(key.lastIndexOf('/') + 1);
+            Path cached = cacheDir.resolve(localName);
+            if (Files.exists(cached) && Files.size(cached) > 0) {
+                return cached.toString();
+            }
 
+            S3Object object = s3Client.getObject(bucket, key);
+            Path tmp = cacheDir.resolve(localName + ".part");
             try (InputStream in = object.getObjectContent();
-                 FileOutputStream out = new FileOutputStream(tempFile.toFile())) {
+                 FileOutputStream out = new FileOutputStream(tmp.toFile())) {
                 byte[] buffer = new byte[8192];
                 int len;
                 while ((len = in.read(buffer)) != -1) {
                     out.write(buffer, 0, len);
                 }
             }
-            return tempFile.toString();
+            Files.move(tmp, cached, StandardCopyOption.REPLACE_EXISTING);
+            cached.toFile().setReadable(true, false);
+            return cached.toString();
         } catch (IOException e) {
             throw new RuntimeException("下载京东云文件失败: " + filename, e);
         }

+ 4 - 1
cfc-backend/src/main/resources/application.yml

@@ -83,7 +83,7 @@ zkxz:
 
 wechat:
   appid: wx5ba8038ef16fb245
-  secret: ${WECHAT_SECRET:dev-only-wechat-secret}
+  secret: ${WECHAT_SECRET:3dae525c6f5e010cc034f59abbd1b1cc}
   mch-id: 1703255039
   mch-key: ${WECHAT_MCH_KEY:dev-only-mch-key}
   api-v3-key: ${WECHAT_API_V3_KEY:dev-only-api-v3-key}
@@ -103,6 +103,9 @@ wechat:
   promotion-tier-template-id: ${WECHAT_PROMOTION_TIER_TEMPLATE_ID:}
   # 小程序码环境:dev=development / test=trial / prod=release
   env-version: ${WECHAT_ENV_VERSION:release}
+  # 桌面端「小程序码」扫码登录:扫码落地的小程序页面路径(须已在 cfc-frontend/pages.json 注册)。
+  # 复用上面的 wechat.appid/secret 调 getwxacodeunlimit,无需「网站应用」凭据,也不产生 GET 回调。
+  wxacode-login-page: ${WECHAT_WXACODE_LOGIN_PAGE:pages/login/scan-login}
 
 dify:
   base-url: ${DIFY_BASE_URL:http://dify.bianwoyou.cn/v1}

+ 8 - 6
cfc-backend/src/main/resources/schema.sql

@@ -1826,7 +1826,6 @@ CREATE TABLE IF NOT EXISTS health_reports (
     gut_type VARCHAR(50) COMMENT '肠型',
     file_url VARCHAR(500) COMMENT '报告文件URL',
     file_hash VARCHAR(64) DEFAULT NULL COMMENT '文件MD5哈希,用于重复上传检测',
-    report_format VARCHAR(32) DEFAULT NULL COMMENT '报告格式: pdf/image',
     status VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/archived',
     remark TEXT COMMENT '备注',
     proxy_uploader_id BIGINT COMMENT '代上传人ID(规划师/营养师)',
@@ -2661,16 +2660,19 @@ CREATE TABLE IF NOT EXISTS cart (
 
 CREATE TABLE IF NOT EXISTS supply_system (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    name VARCHAR(100) NOT NULL COMMENT '体系名称',
+    name VARCHAR(100) NOT NULL COMMENT '体系名称/供应商名称',
     admin_id BIGINT NOT NULL COMMENT '管理员用户ID',
     settlement_period_days INT NOT NULL DEFAULT 30 COMMENT '账期(天)',
     platform_profit_rate DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '平台留利比例(%)',
     description TEXT COMMENT '描述',
     status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/disabled',
+    contact_name VARCHAR(50) COMMENT '联系人',
+    contact_phone VARCHAR(20) COMMENT '联系电话',
+    remark VARCHAR(500) COMMENT '备注',
     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     KEY idx_admin_id (admin_id)
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系';
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商体系(含电商供应商)';
 
 CREATE TABLE IF NOT EXISTS supply_system_member (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
@@ -3944,7 +3946,7 @@ CREATE TABLE IF NOT EXISTS emotion_alert (
 -- ============================================================
 CREATE TABLE IF NOT EXISTS membership_levels (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/BASIC/PROFESSIONAL/ENTERPRISE',
+    level_code VARCHAR(50) NOT NULL COMMENT '等级编码: FREE/PROFESSIONAL/ENTERPRISE',
     level_name VARCHAR(100) NOT NULL COMMENT '等级名称',
     level_desc TEXT COMMENT '等级描述',
     price_monthly INT COMMENT '月费(分)',
@@ -3959,7 +3961,7 @@ CREATE TABLE IF NOT EXISTS membership_levels (
     ai_review_enabled TINYINT(1) DEFAULT 0 COMMENT 'AI审核功能',
     priority_support TINYINT(1) DEFAULT 0 COMMENT '优先客服',
     teacher_consultation TINYINT(1) DEFAULT 0 COMMENT '老师咨询',
-    level_order INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 BASIC=2 FAMILY=3 PROVIDER=4 PREMIUM=5 LIFETIME=6',
+    level_order INT DEFAULT 1 COMMENT '级别数字(越小级别越低): FREE=1 FAMILY=2 PROVIDER=3 PREMIUM=4 LIFETIME=5',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_level_code (level_code)
@@ -3970,7 +3972,7 @@ CREATE TABLE IF NOT EXISTS membership_levels (
 -- ============================================================
 CREATE TABLE IF NOT EXISTS membership_benefits (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/BASIC/PROFESSIONAL/ENTERPRISE',
+    level_code VARCHAR(50) NOT NULL COMMENT '会员等级: FREE/PROFESSIONAL/ENTERPRISE',
     benefit_code VARCHAR(50) NOT NULL COMMENT '权益编码: AI_CHAT,FAMILY_MEMBERS等',
     benefit_name VARCHAR(100) NOT NULL COMMENT '权益名称',
     benefit_desc VARCHAR(500) COMMENT '权益描述',

+ 46 - 0
cfc-desktop-pet/.github/workflows/release.yml

@@ -0,0 +1,46 @@
+name: desktop-pet-release
+
+on:
+  workflow_dispatch:
+  push:
+    tags: ["desktop-pet-v*"]
+
+jobs:
+  build:
+    permissions:
+      contents: write
+    strategy:
+      fail-fast: false
+      matrix:
+        include:
+          - platform: windows-latest
+            args: ""
+          - platform: macos-latest
+            args: "--target universal-apple-darwin"
+    runs-on: ${{ matrix.platform }}
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Rust toolchain
+        uses: dtolnay/rust-toolchain@stable
+        with:
+          targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
+
+      - name: Run tests
+        working-directory: cfc-desktop-pet/src-tauri
+        run: cargo test
+
+      - uses: tauri-apps/tauri-action@v0
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
+          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
+          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+          APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
+          APPLE_ID: ${{ secrets.APPLE_ID }}
+          APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
+          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
+        with:
+          projectPath: cfc-desktop-pet
+          args: ${{ matrix.args }}

+ 3 - 0
cfc-desktop-pet/.gitignore

@@ -0,0 +1,3 @@
+# .NET 构建产物(历史遗留,保留以防本地残留)
+bin/
+obj/

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

@@ -0,0 +1,62 @@
+# 浠艾福桌面宠物 (XafPet)
+
+Windows / macOS 桌面宠物应用(Tauri v2 + Rust),实时反映 CFC 健康陪跑状态。
+
+## 功能
+
+- 显示今日任务进度 / 五维能量 / 健康打卡状态
+- 喝水提醒互动(点击宠物确认打卡);点击任务提醒可直接完成任务
+- 支持浠宝 / 福宝及自定义形象(PNG + `pet-config.json`,支持动作帧序列)
+- 微信扫码登录(网站应用 `snsapi_login`,与小程序账号 unionid SSO)
+- 托盘常驻,右键菜单(设置 / 鼠标穿透 / 退出)
+
+## 技术栈
+
+- 前端:静态 HTML/JS(`src/`),经 Tauri `frontendDist` 打包(`src-tauri/tauri.conf.json`)
+- 后端逻辑:Rust + Tauri v2(`src-tauri/`)
+
+## 构建 / 运行
+
+```bash
+cd cfc-desktop-pet/src-tauri
+cargo tauri build          # 打包发行版
+cargo tauri dev            # 本地调试
+```
+
+## 测试
+
+```bash
+cd cfc-desktop-pet/src-tauri
+cargo test
+```
+
+## 自定义宠物
+
+内置形象位于 `src/DefaultPets/{xibao,fubao}/`(`standing.png` + `pet-config.json`)。
+也可在「设置」页上传自定义形象。
+
+`pet-config.json` 支持 provider 无关的动作帧序列(`animations`):
+
+```json
+{
+  "actions": { "happy": "cheer" },
+  "animations": {
+    "cheer": { "frames": ["cheer_01.png", "cheer_02.png"], "fps": 15, "loop": false }
+  }
+}
+```
+
+`frames[]` 为逐帧图片(相对 `image_base_url` 或绝对 URL);`loop:false` 时播完回到基础形象;未配置 `animations` 时回退为 CSS 动画。
+
+## 环境要求
+
+- Rust stable + Tauri CLI v2(`cargo install tauri-cli --version "^2"`)
+- Windows:WebView2 Runtime(Win 11 自带)+ Visual Studio Build Tools(MSVC)
+- CFC 后端(默认 `https://cfc.cf-club.com`)
+
+## CI
+
+`.github/workflows/release.yml`:推送 `desktop-pet-v*` tag 触发,构建 Windows + macOS 产物。
+
+---
+> **注意:** 本项目为旧版 WPF(`XafPet.Desktop`)的 Tauri 重写。WPF 实现已移除。

+ 19 - 0
cfc-desktop-pet/src-tauri/.cargo/config.toml

@@ -0,0 +1,19 @@
+# 项目级 cargo 配置:crates.io 直连在 CN 网络下 HTTP/2 流重置频繁,改用 rsproxy.cn 稀疏索引镜像
+[source.crates-io]
+replace-with = 'rsproxy-sparse'
+
+[source.rsproxy]
+registry = "https://rsproxy.cn/crates.io-index"
+
+[source.rsproxy-sparse]
+registry = "sparse+https://rsproxy.cn/index/"
+
+[registries.rsproxy]
+index = "https://rsproxy.cn/crates.io-index"
+
+[net]
+git-fetch-with-cli = true
+
+# 规避 HTTP/2 流重置(PROTOCOL_ERROR 0x1)导致 index 更新卡死
+[http]
+multiplexing = false

+ 2 - 0
cfc-desktop-pet/src-tauri/.gitignore

@@ -0,0 +1,2 @@
+/target
+/gen

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

@@ -0,0 +1,5303 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "alloc-no-stdlib"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
+
+[[package]]
+name = "alloc-stdlib"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
+dependencies = [
+ "alloc-no-stdlib",
+]
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "atk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
+dependencies = [
+ "atk-sys",
+ "glib",
+ "libc",
+]
+
+[[package]]
+name = "atk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "blocking"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "brotli"
+version = "9.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+ "brotli-decompressor",
+]
+
+[[package]]
+name = "brotli-decompressor"
+version = "6.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7"
+dependencies = [
+ "alloc-no-stdlib",
+ "alloc-stdlib",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cairo-rs"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
+dependencies = [
+ "bitflags 2.13.2",
+ "cairo-sys-rs",
+ "glib",
+ "libc",
+ "once_cell",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "cairo-sys-rs"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "camino"
+version = "1.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "cargo-platform"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "cargo_metadata"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
+dependencies = [
+ "camino",
+ "cargo-platform",
+ "semver",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "cargo_toml"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9"
+dependencies = [
+ "semver",
+ "serde",
+ "toml 1.1.8+spec-1.1.0",
+]
+
+[[package]]
+name = "cc"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[package]]
+name = "cfb"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3"
+dependencies = [
+ "fnv",
+ "uuid",
+ "web-time",
+]
+
+[[package]]
+name = "cfg-expr"
+version = "0.15.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
+dependencies = [
+ "smallvec",
+ "target-lexicon",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
+dependencies = [
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cssparser"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
+dependencies = [
+ "cssparser-macros",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
+[[package]]
+name = "cssparser-macros"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
+dependencies = [
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "ctor"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
+
+[[package]]
+name = "darling"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "dbus"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e"
+dependencies = [
+ "libc",
+ "libdbus-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "defmt"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+dependencies = [
+ "bitflags 1.3.2",
+ "defmt-macros",
+]
+
+[[package]]
+name = "defmt-macros"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+dependencies = [
+ "defmt-parser",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "defmt-parser"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+dependencies = [
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "derive_more"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "dirs"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "dlopen2"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4"
+dependencies = [
+ "dlopen2_derive",
+ "libc",
+ "once_cell",
+ "winapi",
+]
+
+[[package]]
+name = "dlopen2_derive"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "dom_query"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc"
+dependencies = [
+ "bit-set",
+ "cssparser",
+ "foldhash",
+ "html5ever",
+ "precomputed-hash",
+ "selectors",
+ "tendril",
+]
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "embed-resource"
+version = "3.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
+dependencies = [
+ "cc",
+ "memchr",
+ "rustc_version",
+ "toml 1.1.8+spec-1.1.0",
+ "vswhom",
+ "winreg",
+]
+
+[[package]]
+name = "embed_plist"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased-serde"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+dependencies = [
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "field-offset"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
+dependencies = [
+ "memoffset",
+ "rustc_version",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gdk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
+dependencies = [
+ "cairo-rs",
+ "gdk-pixbuf",
+ "gdk-sys",
+ "gio",
+ "glib",
+ "libc",
+ "pango",
+]
+
+[[package]]
+name = "gdk-pixbuf"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
+dependencies = [
+ "gdk-pixbuf-sys",
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+]
+
+[[package]]
+name = "gdk-pixbuf-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gdk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
+dependencies = [
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkwayland-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pkg-config",
+ "system-deps",
+]
+
+[[package]]
+name = "gdkx11"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe"
+dependencies = [
+ "gdk",
+ "gdkx11-sys",
+ "gio",
+ "glib",
+ "libc",
+ "x11",
+]
+
+[[package]]
+name = "gdkx11-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
+dependencies = [
+ "gdk-sys",
+ "glib-sys",
+ "libc",
+ "system-deps",
+ "x11",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gio"
+version = "0.18.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "gio-sys",
+ "glib",
+ "libc",
+ "once_cell",
+ "pin-project-lite",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "gio-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+ "winapi",
+]
+
+[[package]]
+name = "glib"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
+dependencies = [
+ "bitflags 2.13.2",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-task",
+ "futures-util",
+ "gio-sys",
+ "glib-macros",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "memchr",
+ "once_cell",
+ "smallvec",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "glib-macros"
+version = "0.18.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
+dependencies = [
+ "heck 0.4.1",
+ "proc-macro-crate 2.0.2",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "glib-sys"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
+dependencies = [
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "glob"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
+
+[[package]]
+name = "gobject-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
+dependencies = [
+ "glib-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
+dependencies = [
+ "atk",
+ "cairo-rs",
+ "field-offset",
+ "futures-channel",
+ "gdk",
+ "gdk-pixbuf",
+ "gio",
+ "glib",
+ "gtk-sys",
+ "gtk3-macros",
+ "libc",
+ "pango",
+ "pkg-config",
+]
+
+[[package]]
+name = "gtk-sys"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
+dependencies = [
+ "atk-sys",
+ "cairo-sys-rs",
+ "gdk-pixbuf-sys",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "pango-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "gtk3-macros"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
+dependencies = [
+ "proc-macro-crate 1.3.1",
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "httparse",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ico"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
+dependencies = [
+ "byteorder",
+ "png 0.17.16",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "infer"
+version = "0.22.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4"
+dependencies = [
+ "cfb",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "javascriptcore-rs"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
+dependencies = [
+ "bitflags 1.3.2",
+ "glib",
+ "javascriptcore-rs-sys",
+]
+
+[[package]]
+name = "javascriptcore-rs-sys"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "jiff"
+version = "0.2.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
+dependencies = [
+ "defmt",
+ "jiff-core",
+ "jiff-static",
+ "jiff-tzdb-platform",
+ "log",
+ "portable-atomic",
+ "portable-atomic-util",
+ "serde_core",
+ "windows-link",
+]
+
+[[package]]
+name = "jiff-core"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
+dependencies = [
+ "defmt",
+ "log",
+]
+
+[[package]]
+name = "jiff-static"
+version = "0.2.38"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c"
+dependencies = [
+ "jiff-core",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jiff-tzdb"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7"
+
+[[package]]
+name = "jiff-tzdb-platform"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8"
+dependencies = [
+ "jiff-tzdb",
+]
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "json-patch"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72"
+dependencies = [
+ "jsonptr",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "jsonptr"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "keyboard-types"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
+dependencies = [
+ "bitflags 2.13.2",
+ "serde",
+]
+
+[[package]]
+name = "keyring"
+version = "3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
+dependencies = [
+ "byteorder",
+ "log",
+ "security-framework 2.11.1",
+ "security-framework 3.7.0",
+ "windows-sys 0.60.2",
+ "zeroize",
+]
+
+[[package]]
+name = "libappindicator"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
+dependencies = [
+ "glib",
+ "gtk",
+ "gtk-sys",
+ "libappindicator-sys",
+ "log",
+]
+
+[[package]]
+name = "libappindicator-sys"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
+dependencies = [
+ "gtk-sys",
+ "libloading",
+ "once_cell",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "libdbus-sys"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "libloading"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
+dependencies = [
+ "cfg-if",
+ "winapi",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "muda"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "gtk",
+ "keyboard-types",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.21",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.2",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
+dependencies = [
+ "objc2-encode",
+ "objc2-exception-helper",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-quartz-core",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-exception-helper"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-core-text",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-web-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "pango"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
+dependencies = [
+ "gio",
+ "glib",
+ "libc",
+ "once_cell",
+ "pango-sys",
+]
+
+[[package]]
+name = "pango-sys"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
+dependencies = [
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "plist"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb"
+dependencies = [
+ "base64 0.23.1",
+ "indexmap 2.14.2",
+ "quick-xml",
+ "serde",
+ "time",
+]
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
+
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
+[[package]]
+name = "proc-macro-crate"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
+dependencies = [
+ "once_cell",
+ "toml_edit 0.19.15",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
+dependencies = [
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.17+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.21",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.21",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
+dependencies = [
+ "libredox",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schemars"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
+dependencies = [
+ "dyn-clone",
+ "indexmap 1.9.3",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+ "url",
+ "uuid",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags 2.13.2",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "selectors"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
+dependencies = [
+ "bitflags 2.13.2",
+ "cssparser",
+ "derive_more",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "rustc-hash",
+ "servo_arc",
+ "smallvec",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-untagged"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "serde_core",
+ "typeid",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.152"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_with"
+version = "3.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
+dependencies = [
+ "base64 0.23.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.2",
+ "jiff",
+ "schemars 0.9.0",
+ "schemars 1.2.2",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "serialize-to-javascript"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "serialize-to-javascript-impl",
+]
+
+[[package]]
+name = "serialize-to-javascript-impl"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "servo_arc"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "softbuffer"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3"
+dependencies = [
+ "bytemuck",
+ "js-sys",
+ "ndk",
+ "objc2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "redox_syscall",
+ "tracing",
+ "wasm-bindgen",
+ "web-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "soup3"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
+dependencies = [
+ "futures-channel",
+ "gio",
+ "glib",
+ "libc",
+ "soup3-sys",
+]
+
+[[package]]
+name = "soup3-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
+dependencies = [
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "libc",
+ "system-deps",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "swift-rs"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9"
+dependencies = [
+ "base64 0.21.7",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "system-deps"
+version = "6.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
+dependencies = [
+ "cfg-expr",
+ "heck 0.5.0",
+ "pkg-config",
+ "toml 0.8.2",
+ "version-compare",
+]
+
+[[package]]
+name = "tao"
+version = "0.37.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2",
+ "core-foundation 0.10.1",
+ "core-graphics",
+ "crossbeam-channel",
+ "dbus",
+ "dispatch2",
+ "dlopen2",
+ "dpi",
+ "gdkwayland-sys",
+ "gdkx11-sys",
+ "gtk",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "parking_lot",
+ "percent-encoding",
+ "raw-window-handle",
+ "tao-macros",
+ "unicode-segmentation",
+ "url",
+ "windows",
+ "windows-core",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "tao-macros"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "target-lexicon"
+version = "0.12.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
+
+[[package]]
+name = "tauri"
+version = "2.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c404ecf956ff241bf6b39dd688614724e1bf1f63255abb4fc46d1c40371672ea"
+dependencies = [
+ "anyhow",
+ "bytes",
+ "cookie",
+ "dirs",
+ "dunce",
+ "embed_plist",
+ "getrandom 0.3.4",
+ "glob",
+ "gtk",
+ "heck 0.5.0",
+ "http",
+ "jni",
+ "libc",
+ "log",
+ "mime",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "percent-encoding",
+ "plist",
+ "raw-window-handle",
+ "reqwest 0.13.5",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "serialize-to-javascript",
+ "swift-rs",
+ "tauri-build",
+ "tauri-macros",
+ "tauri-runtime",
+ "tauri-runtime-wry",
+ "tauri-utils",
+ "thiserror 2.0.21",
+ "tokio",
+ "tray-icon",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "window-vibrancy",
+ "windows",
+]
+
+[[package]]
+name = "tauri-build"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs",
+ "glob",
+ "heck 0.5.0",
+ "json-patch",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "tauri-winres",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-codegen"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02f468f9a9bcd404f57e5c191d91024e3de953d4b610f78c2907e7110615cb1c"
+dependencies = [
+ "base64 0.23.1",
+ "brotli",
+ "ico",
+ "json-patch",
+ "plist",
+ "png 0.18.1",
+ "proc-macro2",
+ "quote",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2",
+ "syn 2.0.119",
+ "tauri-utils",
+ "thiserror 2.0.21",
+ "time",
+ "url",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "131bffec939b20642380317f8403a33c8888489925e14bb965ceaff0d949b1cb"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "tauri-codegen",
+ "tauri-utils",
+]
+
+[[package]]
+name = "tauri-plugin-single-instance"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1"
+dependencies = [
+ "serde",
+ "serde_json",
+ "tauri",
+ "thiserror 2.0.21",
+ "tokio",
+ "tracing",
+ "windows-sys 0.61.2",
+ "zbus",
+]
+
+[[package]]
+name = "tauri-runtime"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a57a2a9b8c8b7fc00d35b510fe808ded7f9f1b44fc1cdda6aee38632ea5413"
+dependencies = [
+ "cookie",
+ "dpi",
+ "gtk",
+ "http",
+ "jni",
+ "objc2",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "raw-window-handle",
+ "serde",
+ "serde_json",
+ "tauri-utils",
+ "thiserror 2.0.21",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+]
+
+[[package]]
+name = "tauri-runtime-wry"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f9e7e0c9f80a5130773cfabb6df4dc924518fb1b2c923726966804634b57d22"
+dependencies = [
+ "gtk",
+ "http",
+ "jni",
+ "log",
+ "objc2",
+ "objc2-app-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "softbuffer",
+ "tao",
+ "tauri-runtime",
+ "tauri-utils",
+ "url",
+ "webkit2gtk",
+ "webview2-com",
+ "windows",
+ "wry",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ff55a614843b9a3f010f211df637c94df2607175b8d0f6e0f4a7c19e18621ee"
+dependencies = [
+ "anyhow",
+ "brotli",
+ "cargo_metadata",
+ "ctor",
+ "dom_query",
+ "dunce",
+ "glob",
+ "http",
+ "infer",
+ "json-patch",
+ "log",
+ "memchr",
+ "phf",
+ "plist",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde-untagged",
+ "serde_json",
+ "serde_with",
+ "swift-rs",
+ "thiserror 2.0.21",
+ "toml 1.1.8+spec-1.1.0",
+ "url",
+ "urlpattern",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-winres"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6"
+dependencies = [
+ "dunce",
+ "embed-resource",
+ "toml 1.1.8+spec-1.1.0",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
+dependencies = [
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "toml_edit 0.20.2",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.8+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
+dependencies = [
+ "indexmap 2.14.2",
+ "serde_core",
+ "serde_spanned 1.1.2",
+ "toml_datetime 1.1.2+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.19.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+dependencies = [
+ "indexmap 2.14.2",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
+dependencies = [
+ "indexmap 2.14.2",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.17+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
+dependencies = [
+ "indexmap 2.14.2",
+ "toml_datetime 1.1.2+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+dependencies = [
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tray-icon"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
+dependencies = [
+ "crossbeam-channel",
+ "dirs",
+ "libappindicator",
+ "muda",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation",
+ "once_cell",
+ "png 0.18.1",
+ "serde",
+ "thiserror 2.0.21",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typeid"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicase"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "urlpattern"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b"
+dependencies = [
+ "icu_properties",
+ "regex",
+ "serde",
+ "url",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vswhom"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
+dependencies = [
+ "libc",
+ "vswhom-sys",
+]
+
+[[package]]
+name = "vswhom-sys"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
+dependencies = [
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+]
+
+[[package]]
+name = "webkit2gtk"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-rs",
+ "gdk",
+ "gdk-sys",
+ "gio",
+ "gio-sys",
+ "glib",
+ "glib-sys",
+ "gobject-sys",
+ "gtk",
+ "gtk-sys",
+ "javascriptcore-rs",
+ "libc",
+ "once_cell",
+ "soup3",
+ "webkit2gtk-sys",
+]
+
+[[package]]
+name = "webkit2gtk-sys"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
+dependencies = [
+ "bitflags 1.3.2",
+ "cairo-sys-rs",
+ "gdk-sys",
+ "gio-sys",
+ "glib-sys",
+ "gobject-sys",
+ "gtk-sys",
+ "javascriptcore-rs-sys",
+ "libc",
+ "pkg-config",
+ "soup3-sys",
+ "system-deps",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "webview2-com"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6"
+dependencies = [
+ "webview2-com-macros",
+ "webview2-com-sys",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "webview2-com-macros"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "webview2-com-sys"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb"
+dependencies = [
+ "thiserror 2.0.21",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "window-vibrancy"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580"
+dependencies = [
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-quartz-core",
+ "raw-window-handle",
+ "windows-sys 0.61.2",
+ "windows-version",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winnow"
+version = "0.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winreg"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "wry"
+version = "0.57.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0"
+dependencies = [
+ "base64 0.22.1",
+ "block2",
+ "cookie",
+ "crossbeam-channel",
+ "dirs",
+ "dom_query",
+ "dpi",
+ "dunce",
+ "gdkx11",
+ "gtk",
+ "http",
+ "javascriptcore-rs",
+ "jni",
+ "libc",
+ "ndk",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-core-foundation",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "objc2-web-kit",
+ "once_cell",
+ "percent-encoding",
+ "raw-window-handle",
+ "sha2",
+ "soup3",
+ "tao-macros",
+ "thiserror 2.0.21",
+ "url",
+ "webkit2gtk",
+ "webkit2gtk-sys",
+ "webview2-com",
+ "windows",
+ "windows-core",
+ "windows-version",
+ "x11-dl",
+]
+
+[[package]]
+name = "x11"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
+dependencies = [
+ "libc",
+ "pkg-config",
+]
+
+[[package]]
+name = "x11-dl"
+version = "2.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
+dependencies = [
+ "libc",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "xaf-pet-desktop"
+version = "0.1.0"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "keyring",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_json",
+ "tauri",
+ "tauri-build",
+ "tauri-plugin-single-instance",
+ "tokio",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+ "synstructure",
+]
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow 1.0.4",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
+dependencies = [
+ "serde",
+ "winnow 1.0.4",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zvariant"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow 1.0.4",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
+dependencies = [
+ "proc-macro-crate 3.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.7",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.7",
+ "winnow 1.0.4",
+]

+ 22 - 0
cfc-desktop-pet/src-tauri/Cargo.toml

@@ -0,0 +1,22 @@
+[package]
+name = "xaf-pet-desktop"
+version = "0.1.0"
+description = "浠艾福桌面宠物(Windows + macOS)"
+edition = "2021"
+
+[build-dependencies]
+tauri-build = { version = "2", features = [] }
+
+[dependencies]
+tauri = { version = "2", features = ["tray-icon", "macos-private-api"] }
+tauri-plugin-single-instance = "2"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
+tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] }
+chrono = "0.4"
+base64 = "0.22"
+keyring = { version = "3", features = ["apple-native", "windows-native"] }
+
+[profile.release]
+strip = true

+ 3 - 0
cfc-desktop-pet/src-tauri/build.rs

@@ -0,0 +1,3 @@
+fn main() {
+    tauri_build::build()
+}

+ 7 - 0
cfc-desktop-pet/src-tauri/capabilities/default.json

@@ -0,0 +1,7 @@
+{
+  "$schema": "../gen/schemas/desktop-schema.json",
+  "identifier": "default",
+  "description": "XafPet 前端权限:core 默认集(含 event listen)",
+  "windows": ["main", "settings"],
+  "permissions": ["core:default"]
+}

+ 5 - 0
cfc-desktop-pet/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+  <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
+  <background android:drawable="@color/ic_launcher_background"/>
+</adaptive-icon>

+ 4 - 0
cfc-desktop-pet/src-tauri/icons/android/values/ic_launcher_background.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+  <color name="ic_launcher_background">#FFFFFF</color>
+</resources>

BIN
cfc-desktop-pet/src-tauri/icons/icon.icns


BIN
cfc-desktop-pet/src-tauri/icons/icon.ico


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

@@ -0,0 +1,188 @@
+pub mod models;
+
+pub use models::*;
+use serde::de::DeserializeOwned;
+use std::sync::RwLock;
+
+#[derive(Debug, Clone)]
+pub enum ApiError {
+    Http(String),
+    Parse,
+    Api { code: i32, message: String },
+    EmptyData,
+}
+
+impl std::fmt::Display for ApiError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            ApiError::Http(m) => write!(f, "http: {m}"),
+            ApiError::Parse => write!(f, "响应解析失败"),
+            ApiError::Api { code, message } => write!(f, "服务错误 {code}: {message}"),
+            ApiError::EmptyData => write!(f, "响应 data 为空"),
+        }
+    }
+}
+
+pub struct ApiClient {
+    http: reqwest::Client,
+    base_url: RwLock<String>,
+    token: RwLock<String>,
+}
+
+impl ApiClient {
+    pub fn new(base_url: String, token: String) -> Self {
+        Self {
+            http: reqwest::Client::new(),
+            base_url: RwLock::new(base_url.trim_end_matches('/').to_string()),
+            token: RwLock::new(token),
+        }
+    }
+
+    pub fn update_token(&self, token: String) {
+        *self.token.write().unwrap() = token;
+    }
+
+    pub fn set_base_url(&self, base_url: String) {
+        *self.base_url.write().unwrap() = base_url.trim_end_matches('/').to_string();
+    }
+
+    pub fn parse_response<T: DeserializeOwned>(&self, body: &str) -> Result<T, ApiError> {
+        let result: ApiResult<T> = serde_json::from_str(body).map_err(|_| ApiError::Parse)?;
+        if !result.is_success() {
+            return Err(ApiError::Api {
+                code: result.code,
+                message: result.message.unwrap_or_default(),
+            });
+        }
+        result.data.ok_or(ApiError::EmptyData)
+    }
+
+    async fn post<T: DeserializeOwned>(&self, path: &str, body: Option<&serde_json::Value>) -> Result<T, ApiError> {
+        let url = format!("{}{}", *self.base_url.read().unwrap(), path);
+        let token = self.token.read().unwrap().clone();
+        let mut req = self.http.post(&url).bearer_auth(token);
+        if let Some(b) = body {
+            req = req.json(b);
+        }
+        let resp = req.send().await.map_err(|e| ApiError::Http(e.to_string()))?;
+        let text = resp.text().await.map_err(|e| ApiError::Http(e.to_string()))?;
+        self.parse_response(&text)
+    }
+
+    pub async fn tasks_today(&self) -> Result<Vec<CfcTask>, ApiError> {
+        self.post("/api/tasks/today", Some(&serde_json::json!({}))).await
+    }
+
+    pub async fn energy_overview(&self) -> Result<EnergyOverview, ApiError> {
+        // 后端 @RequestBody(required=false),但实测必须带 body 才会进入业务逻辑
+        self.post("/api/energy/overview", Some(&serde_json::json!({}))).await
+    }
+
+    /// 最近喝水记录(memberId 由后端从 JWT 解析)
+    pub async fn water_list(&self) -> Result<Vec<WaterRecord>, ApiError> {
+        self.post("/api/health/water/list", Some(&serde_json::json!({}))).await
+    }
+
+    /// 完成一次喝水打卡(固定快捷打卡:250ml / plain / 当前时间)
+    pub async fn complete_water(&self) -> Result<WaterRecord, ApiError> {
+        let drunk_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
+        self.post(
+            "/api/health/water/create",
+            Some(&serde_json::json!({
+                "amountMl": 250,
+                "waterType": "plain",
+                "drunkAt": drunk_at
+            })),
+        )
+        .await
+    }
+
+    pub async fn mascot_get(&self) -> Result<MascotInfo, ApiError> {
+        self.post("/api/user/mascot/get", None).await
+    }
+
+    /// 一键完成任务(memberId 由后端从 JWT 解析)
+    pub async fn complete_task(&self, task_id: i64) -> Result<serde_json::Value, ApiError> {
+        self.post(&format!("/api/tasks/{task_id}/complete"), Some(&serde_json::json!({}))).await
+    }
+
+    /// 上传图片等媒体文件(multipart,复用 /api/media/upload)
+    pub async fn upload_media(
+        &self,
+        filename: &str,
+        bytes: Vec<u8>,
+        mime: &str,
+    ) -> Result<MediaRecord, ApiError> {
+        let url = format!("{}/api/media/upload", *self.base_url.read().unwrap());
+        let token = self.token.read().unwrap().clone();
+        let part = reqwest::multipart::Part::bytes(bytes.clone()).file_name(filename.to_string());
+        let part = if mime.is_empty() {
+            part
+        } else {
+            part.mime_str(mime).unwrap_or_else(|_| {
+                reqwest::multipart::Part::bytes(bytes).file_name(filename.to_string())
+            })
+        };
+        let form = reqwest::multipart::Form::new().part("file", part);
+        let resp = self
+            .http
+            .post(&url)
+            .bearer_auth(token)
+            .multipart(form)
+            .send()
+            .await
+            .map_err(|e| ApiError::Http(e.to_string()))?;
+        let text = resp.text().await.map_err(|e| ApiError::Http(e.to_string()))?;
+        self.parse_response(&text)
+    }
+
+    /// 微信网站应用扫码登录:获取二维码链接
+    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)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parse_success_response() {
+        let client = ApiClient::new("http://x".into(), "t".into());
+        let data: Vec<CfcTask> = client
+            .parse_response(r#"{"code":200,"data":[{"id":1,"title":"t","status":"PENDING","deadline":"2026-08-15T00:00:00"}]}"#)
+            .unwrap();
+        assert_eq!(data.len(), 1);
+        assert_eq!(
+            data[0].deadline.as_ref().unwrap().to_local_date(),
+            chrono::NaiveDate::from_ymd_opt(2026, 8, 15)
+        );
+    }
+
+    #[test]
+    fn parse_error_code_returns_api_error() {
+        let client = ApiClient::new("http://x".into(), "t".into());
+        let err = client.parse_response::<serde_json::Value>(r#"{"code":401,"message":"unauthorized"}"#).unwrap_err();
+        match err {
+            ApiError::Api { code, .. } => assert_eq!(code, 401),
+            other => panic!("unexpected error: {other:?}"),
+        }
+    }
+
+    #[test]
+    fn parse_malformed_returns_parse_error() {
+        let client = ApiClient::new("http://x".into(), "t".into());
+        assert!(matches!(client.parse_response::<serde_json::Value>("not json"), Err(ApiError::Parse)));
+    }
+
+    #[test]
+    fn parse_empty_data_returns_empty_error() {
+        let client = ApiClient::new("http://x".into(), "t".into());
+        assert!(matches!(client.parse_response::<CfcTask>(r#"{"code":200}"#), Err(ApiError::EmptyData)));
+    }
+}

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

@@ -0,0 +1,252 @@
+use chrono::TimeZone;
+use serde::Deserialize;
+
+/// CFC 统一响应包装 Result<T>(code=200 视为成功)
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ApiResult<T> {
+    pub code: i32,
+    pub message: Option<String>,
+    pub data: Option<T>,
+}
+
+impl<T> ApiResult<T> {
+    pub fn is_success(&self) -> bool {
+        self.code == 200
+    }
+}
+
+/// 兼容后端可能返回的日期格式:ISO 8601 字符串 或 epoch 毫秒数字。
+///
+/// Spring Boot 默认把 `java.util.Date` 序列化为 epoch 毫秒(数字);项目内个别
+/// 字段用 `@JsonFormat` 输出字符串。两种都接受,统一归一化为本地日期。
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+pub enum FlexDate {
+    Iso(String),
+    Millis(i64),
+}
+
+impl FlexDate {
+    /// 归一化为本地时区日期(yyyy-MM-dd)。
+    pub fn to_local_date(&self) -> Option<chrono::NaiveDate> {
+        match self {
+            FlexDate::Iso(s) => {
+                if s.len() < 10 {
+                    return None;
+                }
+                let head = &s[..10];
+                chrono::NaiveDate::parse_from_str(head, "%Y-%m-%d").ok()
+            }
+            FlexDate::Millis(ms) => chrono::Local
+                .timestamp_millis_opt(*ms)
+                .single()
+                .map(|dt| dt.date_naive()),
+        }
+    }
+}
+
+/// POST /api/tasks/today 返回的 Task(`tasks` 表)
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CfcTask {
+    pub id: i64,
+    pub title: Option<String>,
+    pub status: Option<String>, // PENDING / IN_PROGRESS / COMPLETED / REJECTED
+    pub deadline: Option<FlexDate>,
+}
+
+/// /api/energy/overview 五维维度项:{code,name,icon,element,energy,healthIndex}
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DimensionValue {
+    pub code: Option<String>, // body/mind/wisdom/action/wealth
+    pub name: Option<String>, // 身/心/智/行/富
+    pub icon: Option<String>,
+    pub element: Option<String>,
+    #[serde(default)]
+    pub energy: i32,
+    #[serde(default)]
+    pub health_index: i32,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct EnergyOverview {
+    #[serde(default)]
+    pub dimensions: Vec<DimensionValue>,
+    #[serde(default)]
+    pub total_energy: i32,
+    #[serde(default)]
+    pub total_health_index: i32,
+}
+
+/// /api/user/mascot/get → {"mascot": "xibao"|"fubao"|null}
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MascotInfo {
+    pub mascot: Option<String>,
+}
+
+/// /api/health/water/list|create(`water_record` 表)
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct WaterRecord {
+    pub id: Option<i64>,
+    pub member_id: Option<i64>,
+    pub checkin_id: Option<i64>,
+    pub amount_ml: Option<i32>,
+    pub water_type: Option<String>,
+    pub drunk_at: Option<FlexDate>,
+    pub created_at: Option<FlexDate>,
+}
+
+/// /api/media/upload 返回的媒体记录(仅取所需字段,其余忽略)
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MediaRecord {
+    #[allow(dead_code)]
+    pub id: Option<i64>,
+    pub url: Option<String>,
+}
+
+/// POST /api/auth/wechat-web/qr → {state, qrImage}
+/// qrImage 为小程序码的 data:image/png;base64,... 数据 URI(测试模式为空串)
+#[derive(Debug, Clone, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct WechatQrResp {
+    pub state: String,
+    #[serde(default)]
+    pub qr_image: Option<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>,
+    pub message: Option<String>,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_media_record_upload_response() {
+        let json = r#"{"code":200,"message":"success","data":{"id":9,"type":"image","url":"https://cdn/x.png","creatorId":null}}"#;
+        let r: ApiResult<MediaRecord> = serde_json::from_str(json).unwrap();
+        assert!(r.is_success());
+        let rec = r.data.unwrap();
+        assert_eq!(rec.id, Some(9));
+        assert_eq!(rec.url.as_deref(), Some("https://cdn/x.png"));
+    }
+
+    #[test]
+    fn parses_task_with_iso_deadline() {
+        let json = r#"{"id":3,"title":"喝水","status":"PENDING","deadline":"2026-08-15T10:00:00"}"#;
+        let task: CfcTask = serde_json::from_str(json).unwrap();
+        assert_eq!(task.id, 3);
+        assert_eq!(task.status.as_deref(), Some("PENDING"));
+        assert_eq!(
+            task.deadline.as_ref().unwrap().to_local_date(),
+            chrono::NaiveDate::from_ymd_opt(2026, 8, 15)
+        );
+    }
+
+    #[test]
+    fn parses_task_with_millis_deadline() {
+        let json = r#"{"id":4,"title":"t","status":"PENDING","deadline":1786752000000}"#;
+        let task: CfcTask = serde_json::from_str(json).unwrap();
+        assert!(task.deadline.as_ref().unwrap().to_local_date().is_some());
+    }
+
+    #[test]
+    fn parses_task_with_null_deadline() {
+        let json = r#"{"id":5,"title":"t","status":"PENDING","deadline":null}"#;
+        let task: CfcTask = serde_json::from_str(json).unwrap();
+        assert!(task.deadline.is_none());
+    }
+
+    #[test]
+    fn flex_date_millis_zero_is_a_valid_date() {
+        let d: FlexDate = serde_json::from_str("0").unwrap();
+        assert!(d.to_local_date().is_some());
+    }
+
+    #[test]
+    fn parses_energy_overview_and_defaults_to_empty() {
+        let json = r##"{"dimensions":[{"code":"body","name":"身","energy":65,"healthIndex":0}],"totalEnergy":65,"totalHealthIndex":0}"##;
+        let e: EnergyOverview = serde_json::from_str(json).unwrap();
+        assert_eq!(e.dimensions.len(), 1);
+        assert_eq!(e.dimensions[0].energy, 65);
+        assert_eq!(e.dimensions[0].code.as_deref(), Some("body"));
+
+        let empty: EnergyOverview = serde_json::from_str(r#"{}"#).unwrap();
+        assert!(empty.dimensions.is_empty());
+        assert_eq!(empty.total_energy, 0);
+    }
+
+    #[test]
+    fn parses_mascot_response() {
+        let json = r#"{"code":200,"message":"success","data":{"mascot":"xibao"}}"#;
+        let r: ApiResult<MascotInfo> = serde_json::from_str(json).unwrap();
+        assert!(r.is_success());
+        assert_eq!(r.data.unwrap().mascot.as_deref(), Some("xibao"));
+    }
+
+    #[test]
+    fn parses_mascot_null() {
+        let json = r#"{"code":200,"message":"success","data":{"mascot":null}}"#;
+        let r: ApiResult<MascotInfo> = serde_json::from_str(json).unwrap();
+        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","qrImage":"data:image/png;base64,AAAA"}}"#;
+        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
+            .qr_image
+            .as_deref()
+            .unwrap_or("")
+            .starts_with("data:image/png;base64,"));
+    }
+
+    #[test]
+    fn parses_wechat_qr_response_empty_image() {
+        // 测试模式后端返回 qrImage="":字段存在但为空
+        let json = r#"{"code":200,"data":{"state":"abc123","qrImage":""}}"#;
+        let r: ApiResult<WechatQrResp> = serde_json::from_str(json).unwrap();
+        let q = r.data.unwrap();
+        assert_eq!(q.state, "abc123");
+        assert_eq!(q.qr_image.as_deref(), Some(""));
+    }
+
+    #[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());
+    }
+}

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

@@ -0,0 +1,10 @@
+use crate::api::ApiClient;
+use std::sync::{Arc, Mutex};
+
+pub struct AppState {
+    pub passthrough: Mutex<bool>,
+    pub poll_handle: Mutex<Option<tauri::async_runtime::JoinHandle<()>>>,
+    /// 当前提醒中、可一键完成的待办任务 id(点击宠物即完成)
+    pub pending_task: Mutex<Option<i64>>,
+    pub api: Arc<ApiClient>,
+}

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

@@ -0,0 +1,306 @@
+use crate::app_state::AppState;
+use crate::poller;
+use crate::settings::SettingsStore;
+use crate::state::{PetState, PetStatePayload};
+use crate::token::TokenStore;
+use tauri::{AppHandle, Emitter, Manager, State};
+
+#[tauri::command]
+pub async fn on_interact(app: AppHandle, action: String, _data: Option<String>) -> Result<(), String> {
+    match action.as_str() {
+        "click" => {
+            // 有可一键完成的待办任务时优先完成它,否则回退到喝水打卡
+            let pending = app
+                .try_state::<AppState>()
+                .and_then(|s| *s.pending_task.lock().unwrap());
+            match pending {
+                Some(id) => complete_task_by_id(&app, id).await,
+                None => complete_water(&app).await,
+            }
+        }
+        "pet" => {
+            let _ = app.emit("pet://bubble", serde_json::json!({"text": "嘻嘻~", "durationMs": 2000}));
+            Ok(())
+        }
+        _ => Ok(()),
+    }
+}
+
+#[tauri::command]
+pub async fn drag_start(app: AppHandle) -> Result<(), String> {
+    if let Some(win) = app.get_webview_window("main") {
+        win.start_dragging().map_err(|e| e.to_string())?;
+    }
+    Ok(())
+}
+
+#[tauri::command]
+pub async fn drag_end() -> Result<(), String> {
+    Ok(())
+}
+
+#[tauri::command]
+pub fn open_settings(app: AppHandle) -> Result<(), String> {
+    if let Some(win) = app.get_webview_window("settings") {
+        let _ = win.show();
+        let _ = win.set_focus();
+    }
+    Ok(())
+}
+
+#[tauri::command]
+pub fn quit_app(app: AppHandle) -> Result<(), String> {
+    app.exit(0);
+    Ok(())
+}
+
+#[tauri::command]
+pub fn get_settings(
+    _state: State<AppState>,
+    settings: State<SettingsStore>,
+) -> Result<serde_json::Value, String> {
+    let cfg = settings.load();
+    let has_token = TokenStore::new().load().map(|t| t.is_some()).unwrap_or(false);
+    Ok(serde_json::json!({
+        "baseUrl": cfg.base_url,
+        "hasToken": has_token,
+        "customMascotUrl": cfg.custom_mascot_url
+    }))
+}
+
+#[tauri::command]
+pub async fn upload_mascot(
+    app: AppHandle,
+    settings: State<'_, SettingsStore>,
+    filename: String,
+    mime: String,
+    data_base64: String,
+) -> Result<String, String> {
+    use base64::Engine;
+    let bytes = base64::engine::general_purpose::STANDARD
+        .decode(data_base64.as_bytes())
+        .map_err(|e| format!("图片解码失败: {e}"))?;
+    if bytes.is_empty() {
+        return Err("图片内容为空".into());
+    }
+    if bytes.len() > 10 * 1024 * 1024 {
+        return Err("图片过大(上限 10MB)".into());
+    }
+
+    // 生成 data URL(前端 canvas 生成动作帧用,规避远程图片跨域污染)。
+    let data_url = {
+        let m = if mime.is_empty() { "image/png".to_string() } else { mime.clone() };
+        format!("data:{};base64,{}", m, base64::engine::general_purpose::STANDARD.encode(&bytes))
+    };
+
+    let state = app.state::<AppState>();
+    let record = state
+        .api
+        .upload_media(&filename, bytes, &mime)
+        .await
+        .map_err(|e| format!("上传失败: {e}"))?;
+    let url = record.url.ok_or_else(|| "上传响应缺少 url".to_string())?;
+
+    let mut cfg = settings.load();
+    cfg.custom_mascot_url = Some(url.clone());
+    settings.save(&cfg)?;
+
+    let _ = app.emit(
+        "pet://custom-image",
+        serde_json::json!({ "url": url, "dataUrl": data_url }),
+    );
+    Ok(url)
+}
+
+#[tauri::command]
+pub fn clear_custom_mascot(app: AppHandle, settings: State<SettingsStore>) -> Result<(), String> {
+    let mut cfg = settings.load();
+    cfg.custom_mascot_url = None;
+    settings.save(&cfg)?;
+    let _ = app.emit("pet://custom-image", serde_json::json!({ "url": null }));
+    Ok(())
+}
+
+/// 拉取远程图片并转成 data URL(供前端 canvas 生成动作帧,规避跨域污染)。
+#[tauri::command]
+pub async fn data_url_from_url(url: String) -> Result<String, String> {
+    if url.starts_with("data:") {
+        return Ok(url);
+    }
+    let resp = reqwest::get(&url).await.map_err(|e| format!("请求失败: {e}"))?;
+    if !resp.status().is_success() {
+        return Err(format!("请求失败: HTTP {}", resp.status()));
+    }
+    let mime = resp
+        .headers()
+        .get(reqwest::header::CONTENT_TYPE)
+        .and_then(|v| v.to_str().ok())
+        .unwrap_or("image/png")
+        .to_string();
+    let bytes = resp.bytes().await.map_err(|e| format!("读取失败: {e}"))?;
+    use base64::Engine;
+    let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
+    Ok(format!("data:{};base64,{}", mime, b64))
+}
+
+/// 微信小程序码扫码登录:取小程序码图片 → 前端展示 → 后台轮询换取 JWT → 写入 keyring。
+/// 二维码图片通过 `pet://wechat-qr` 事件回传({state, qrImage});
+/// 登录结果通过 `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}"))?
+    };
+
+    // 将小程序码图片推给设置窗口展示(用户用微信扫码后在小程序内确认登录)
+    let _ = app.emit(
+        "pet://wechat-qr",
+        serde_json::json!({ "state": qr.state.clone(), "qrImage": qr.qr_image.clone() }),
+    );
+
+    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,
+                                "userId": resp.user_id,
+                                "role": resp.role
+                            }),
+                        );
+                        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", "qrImage": qr.qr_image }))
+}
+
+#[tauri::command]
+pub fn save_settings(
+    app: AppHandle,
+    state: State<AppState>,
+    settings: State<SettingsStore>,
+    base_url: String,
+    token: Option<String>,
+) -> Result<(), String> {
+    let mut cfg = settings.load();
+    cfg.base_url = base_url.trim().to_string();
+    settings.save(&cfg)?;
+
+    if let Some(t) = token {
+        if !t.trim().is_empty() {
+            TokenStore::new().save(t.trim())?;
+        }
+    }
+
+    // 更新运行时客户端并重启轮询(对齐现 ShowSettings 的 Stop/Start)
+    state.api.set_base_url(cfg.base_url.clone());
+    if let Some(t) = TokenStore::new().load()? {
+        state.api.update_token(t);
+    }
+    restart_polling(&app, &state);
+
+    if let Some(win) = app.get_webview_window("settings") {
+        let _ = win.hide();
+    }
+    Ok(())
+}
+
+fn restart_polling(app: &AppHandle, state: &State<AppState>) {
+    if let Some(h) = state.poll_handle.lock().unwrap().take() {
+        h.abort();
+    }
+    let handle = poller::spawn(app.clone(), state.api.clone());
+    *state.poll_handle.lock().unwrap() = Some(handle);
+}
+
+async fn complete_water(app: &AppHandle) -> Result<(), String> {
+    let state = app.state::<AppState>();
+    let api = state.api.clone();
+    match api.complete_water().await {
+        Ok(_) => {
+            let payload = PetStatePayload {
+                state: PetState::Happy,
+                reason: Some("drink_complete".into()),
+                data: None,
+            };
+            let _ = app.emit("pet://state", payload);
+            let _ = app.emit("pet://bubble", serde_json::json!({"text": "喝水打卡成功!太棒了~", "durationMs": 4000}));
+            Ok(())
+        }
+        Err(_) => {
+            let _ = app.emit("pet://bubble", serde_json::json!({"text": "打卡失败,请稍后再试", "durationMs": 4000}));
+            Err("checkin failed".into())
+        }
+    }
+}
+
+async fn complete_task_by_id(app: &AppHandle, task_id: i64) -> Result<(), String> {
+    let state = app.state::<AppState>();
+    let api = state.api.clone();
+    match api.complete_task(task_id).await {
+        Ok(_) => {
+            if let Some(s) = app.try_state::<AppState>() {
+                *s.pending_task.lock().unwrap() = None;
+            }
+            let payload = PetStatePayload {
+                state: PetState::Happy,
+                reason: Some("task_complete".into()),
+                data: None,
+            };
+            let _ = app.emit("pet://state", payload);
+            let _ = app.emit("pet://bubble", serde_json::json!({"text": "任务完成!太棒了~", "durationMs": 4000}));
+            Ok(())
+        }
+        Err(_) => {
+            let _ = app.emit("pet://bubble", serde_json::json!({"text": "完成任务失败,请稍后再试", "durationMs": 4000}));
+            Err("task complete failed".into())
+        }
+    }
+}

+ 187 - 0
cfc-desktop-pet/src-tauri/src/config.rs

@@ -0,0 +1,187 @@
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum PetType {
+    #[default]
+    Animal,
+    Human,
+    Custom,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct PetConfig {
+    #[serde(default = "default_version")]
+    pub version: String,
+    #[serde(rename = "type", default)]
+    pub pet_type: PetType,
+    #[serde(default = "default_species")]
+    pub species: String,
+    #[serde(default = "default_gender")]
+    pub gender: String,
+    #[serde(default)]
+    pub image_base_url: String,
+    #[serde(default = "default_image")]
+    pub default_image: String,
+    #[serde(default)]
+    pub actions: HashMap<String, String>,
+    /// 动作名 → 帧序列剪辑(真运动序列帧)。provider 无关:
+    /// frames[] 为逐帧图片(相对 image_base_url 或绝对 URL);sprite 为图集(可选)。
+    #[serde(default)]
+    pub animations: HashMap<String, ActionClip>,
+    #[serde(default)]
+    pub sounds: HashMap<String, String>,
+    #[serde(default)]
+    pub parameters: PetParameters,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct PetParameters {
+    #[serde(default = "default_move_distance")]
+    pub move_distance: i32,
+    #[serde(default = "default_animation_speed")]
+    pub animation_speed: f64,
+    #[serde(default = "default_interaction_timeout")]
+    pub interaction_timeout: i32,
+}
+
+impl Default for PetParameters {
+    fn default() -> Self {
+        Self {
+            move_distance: default_move_distance(),
+            animation_speed: default_animation_speed(),
+            interaction_timeout: default_interaction_timeout(),
+        }
+    }
+}
+
+/// 单个动作的帧序列剪辑(provider 无关的动作动画基础)。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub struct ActionClip {
+    /// 逐帧图片列表(8-16 帧)。相对路径以 image_base_url 为前缀。
+    #[serde(default)]
+    pub frames: Vec<String>,
+    /// 图集图片(可选,优先级低于 frames)。
+    #[serde(default)]
+    pub sprite: Option<String>,
+    #[serde(default = "default_frame_width")]
+    pub frame_width: u32,
+    #[serde(default = "default_frame_height")]
+    pub frame_height: u32,
+    #[serde(default)]
+    pub count: u32,
+    #[serde(default = "default_fps")]
+    pub fps: f64,
+    #[serde(default = "default_true")]
+    pub r#loop: bool,
+}
+
+fn default_version() -> String { "1.0".into() }
+fn default_species() -> String { "other".into() }
+fn default_gender() -> String { "neutral".into() }
+fn default_image() -> String { "standing.png".into() }
+fn default_move_distance() -> i32 { 15 }
+fn default_animation_speed() -> f64 { 1.0 }
+fn default_interaction_timeout() -> i32 { 300 }
+fn default_frame_width() -> u32 { 128 }
+fn default_frame_height() -> u32 { 128 }
+fn default_fps() -> f64 { 12.0 }
+fn default_true() -> bool { true }
+
+impl PetConfig {
+    /// 解析 JSON;actions 为空时补默认映射(idle/happy/busy/sleeping)
+    pub fn parse(json: &str) -> Result<Self, serde_json::Error> {
+        let mut cfg: PetConfig = serde_json::from_str(json)?;
+        if cfg.actions.is_empty() {
+            cfg.actions = [
+                ("idle".to_string(), "idle".to_string()),
+                ("happy".to_string(), "happy".to_string()),
+                ("busy".to_string(), "busy".to_string()),
+                ("sleeping".to_string(), "sleeping".to_string()),
+            ]
+            .into_iter()
+            .collect();
+        }
+        Ok(cfg)
+    }
+
+    pub fn get_action_for_state(&self, state_name: &str) -> String {
+        self.actions.get(state_name).cloned().unwrap_or_else(|| state_name.to_string())
+    }
+
+    /// 解析某状态对应的帧序列剪辑(provider 无关)。无则 None(前端回退 CSS 动画)。
+    pub fn get_clip_for_state(&self, state_name: &str) -> Option<&ActionClip> {
+        let action = self.get_action_for_state(state_name);
+        self.animations.get(&action)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const XIBAO_JSON: &str = include_str!("../../src/DefaultPets/xibao/pet-config.json");
+
+    #[test]
+    fn parses_real_xibao_config() {
+        let cfg = PetConfig::parse(XIBAO_JSON).unwrap();
+        assert_eq!(cfg.version, "1.0");
+        assert!(matches!(cfg.pet_type, PetType::Animal));
+        assert_eq!(cfg.image_base_url, "DefaultPets/xibao/");
+        assert_eq!(cfg.default_image, "standing.png");
+        assert!(cfg.actions.contains_key("idle"));
+        assert_eq!(cfg.get_action_for_state("happy"), "wings_spread");
+    }
+
+    #[test]
+    fn fills_default_actions_when_empty() {
+        let json = r#"{"version":"1.0","type":"custom","actions":{}}"#;
+        let cfg = PetConfig::parse(json).unwrap();
+        assert_eq!(cfg.actions.len(), 4);
+        assert_eq!(cfg.actions["idle"], "idle");
+    }
+
+    #[test]
+    fn action_falls_back_to_state_name() {
+        let json = r#"{"type":"custom","actions":{"idle":"idle"}}"#;
+        let cfg = PetConfig::parse(json).unwrap();
+        assert_eq!(cfg.get_action_for_state("no_such_state"), "no_such_state");
+    }
+
+    #[test]
+    fn parse_error_on_bad_json() {
+        assert!(PetConfig::parse("not json").is_err());
+    }
+
+    #[test]
+    fn parses_action_frames_and_resolves_by_state() {
+        let json = r#"{"type":"custom","actions":{"happy":"cheer"},"animations":{"cheer":{"frames":["a_01.png","a_02.png","a_03.png"],"fps":15,"loop":false}}}"#;
+        let cfg = PetConfig::parse(json).unwrap();
+        let clip = cfg.get_clip_for_state("happy").expect("clip");
+        assert_eq!(clip.frames.len(), 3);
+        assert_eq!(clip.fps, 15.0);
+        assert!(!clip.r#loop);
+        assert_eq!(clip.frame_width, 128);
+        assert_eq!(clip.frame_height, 128);
+        assert_eq!(clip.count, 0);
+    }
+
+    #[test]
+    fn no_clip_when_animations_absent() {
+        let json = r#"{"type":"custom","actions":{"idle":"idle"}}"#;
+        let cfg = PetConfig::parse(json).unwrap();
+        assert!(cfg.get_clip_for_state("idle").is_none());
+        assert!(cfg.animations.is_empty());
+    }
+
+    #[test]
+    fn action_clip_loop_defaults_true() {
+        let json = r#"{"type":"custom","actions":{"idle":"idle"},"animations":{"idle":{"frames":["x.png"]}}}"#;
+        let cfg = PetConfig::parse(json).unwrap();
+        assert!(cfg.get_clip_for_state("idle").unwrap().r#loop);
+    }
+}

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

@@ -0,0 +1,59 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+mod api;
+mod app_state;
+mod commands;
+mod config;
+mod poller;
+mod settings;
+mod state;
+mod token;
+mod tray;
+
+use app_state::AppState;
+use std::sync::Arc;
+use tauri::Manager;
+
+fn main() {
+    tauri::Builder::default()
+        // 单实例插件必须最先注册
+        .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
+            if let Some(win) = app.get_webview_window("main") {
+                let _ = win.show();
+                let _ = win.set_focus();
+            }
+        }))
+        .setup(|app| {
+            let config_dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
+            let settings = settings::SettingsStore::new(config_dir);
+            let cfg = settings.load();
+            let token = token::TokenStore::new().load().ok().flatten().unwrap_or_else(|| "dev-token".into());
+            let api = Arc::new(api::ApiClient::new(cfg.base_url.clone(), token));
+
+            let handle = poller::spawn(app.handle().clone(), api.clone());
+            app.manage(settings);
+            app.manage(AppState {
+                passthrough: std::sync::Mutex::new(false),
+                poll_handle: std::sync::Mutex::new(Some(handle)),
+                pending_task: std::sync::Mutex::new(None),
+                api,
+            });
+            tray::setup_tray(app)?;
+            Ok(())
+        })
+        .invoke_handler(tauri::generate_handler![
+            commands::on_interact,
+            commands::drag_start,
+            commands::drag_end,
+            commands::get_settings,
+            commands::save_settings,
+            commands::open_settings,
+            commands::quit_app,
+            commands::upload_mascot,
+            commands::clear_custom_mascot,
+            commands::data_url_from_url,
+            commands::start_wechat_login,
+        ])
+        .run(tauri::generate_context!())
+        .expect("error while running tauri application");
+}

+ 236 - 0
cfc-desktop-pet/src-tauri/src/poller.rs

@@ -0,0 +1,236 @@
+use crate::api::{models::{CfcTask, WaterRecord}, ApiClient};
+use crate::app_state::AppState;
+use crate::config::PetConfig;
+use crate::state::{map_state, PetState, PetStatePayload};
+use chrono::Timelike;
+use std::sync::Arc;
+use std::time::Duration;
+use tauri::{AppHandle, Emitter, Manager};
+
+/// 启动轮询任务:启动即同步吉祥物+喝水检查,然后 30s 任务 / 60s 健康 / 1h 喝水。
+/// 返回 JoinHandle 以便 save_settings 时 abort 重启。
+pub fn spawn(app: AppHandle, api: Arc<ApiClient>) -> tauri::async_runtime::JoinHandle<()> {
+    tauri::async_runtime::spawn(async move {
+        sync_mascot(&app, &api).await;
+        check_water(&app, &api).await;
+
+        let mut tasks_iv = tokio::time::interval(Duration::from_secs(30));
+        let mut health_iv = tokio::time::interval(Duration::from_secs(60));
+        let mut water_iv = tokio::time::interval(Duration::from_secs(3600));
+
+        loop {
+            tokio::select! {
+                _ = tasks_iv.tick() => poll_tasks(&app, &api).await,
+                _ = health_iv.tick() => poll_health(&app, &api).await,
+                _ = water_iv.tick() => check_water(&app, &api).await,
+            }
+        }
+    })
+}
+
+fn in_active_hour(hour: u32) -> bool {
+    (10..=20).contains(&hour)
+}
+
+async fn sync_mascot(app: &AppHandle, api: &ApiClient) {
+    let Ok(mascot) = api.mascot_get().await else { return };
+    let pet_name = mascot.mascot.as_deref().unwrap_or("xibao");
+    let path = format!("DefaultPets/{pet_name}/pet-config.json");
+
+    // 优先读嵌入资产;失败回退 resource_dir
+    let bytes = app
+        .asset_resolver()
+        .get(path.clone())
+        .map(|a| a.bytes)
+        .or_else(|| std::fs::read(app.path().resource_dir().unwrap_or_default().join(&path)).ok());
+    let Some(bytes) = bytes else { return };
+
+    if let Ok(json) = String::from_utf8(bytes) {
+        if let Ok(cfg) = PetConfig::parse(&json) {
+            if let Ok(payload) = serde_json::to_string(&cfg) {
+                let _ = app.emit("pet://config", payload);
+            }
+        }
+    }
+}
+
+async fn poll_tasks(app: &AppHandle, api: &ApiClient) {
+    match api.tasks_today().await {
+        Ok(tasks) => {
+            let state = map_state(Some(&tasks), None, false, false);
+            let payload = PetStatePayload { state, reason: Some("tasks".into()), data: None };
+            let _ = app.emit("pet://state", payload);
+
+            // 选出最紧急的待办任务作为「点提醒即完成」的目标
+            let urgent = pick_urgent_task(&tasks);
+            let new_id = urgent.as_ref().map(|t| t.id);
+            let changed = match app.try_state::<AppState>() {
+                Some(s) => {
+                    let mut cur = s.pending_task.lock().unwrap();
+                    let changed = *cur != new_id;
+                    *cur = new_id;
+                    changed
+                }
+                None => false,
+            };
+            if changed {
+                if let Some(t) = &urgent {
+                    let title = t.title.clone().unwrap_or_else(|| "待办任务".into());
+                    let _ = app.emit(
+                        "pet://bubble",
+                        serde_json::json!({
+                            "text": format!("「{title}」快到点了,点我完成!"),
+                            "durationMs": 15000
+                        }),
+                    );
+                }
+            }
+        }
+        Err(_) => {
+            let _ = app.emit(
+                "pet://bubble",
+                serde_json::json!({"text": "连接后端失败,请检查服务", "durationMs": 5000}),
+            );
+        }
+    }
+}
+
+/// 从今日任务中选出最紧急、可一键完成的待办任务(未完成且已到期/今日到期)
+fn pick_urgent_task(tasks: &[CfcTask]) -> Option<CfcTask> {
+    let today = chrono::Local::now().date_naive();
+    tasks
+        .iter()
+        .filter(|t| matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS")))
+        .filter(|t| {
+            t.deadline
+                .as_ref()
+                .and_then(|d| d.to_local_date())
+                .map(|due| due <= today)
+                .unwrap_or(false)
+        })
+        .min_by_key(|t| t.deadline.as_ref().and_then(|d| d.to_local_date()))
+        .cloned()
+}
+
+async fn poll_health(app: &AppHandle, api: &ApiClient) {
+    match (api.energy_overview().await, api.water_list().await) {
+        (Ok(energy), Ok(water)) => {
+            let done = water_done_today(&water);
+            let state = map_state(None, Some(&energy), done, false);
+            let payload = PetStatePayload { state, reason: Some("health".into()), data: None };
+            let _ = app.emit("pet://state", payload);
+        }
+        _ => { /* 静默,与现版一致 */ }
+    }
+}
+
+async fn check_water(app: &AppHandle, api: &ApiClient) {
+    let hour = chrono::Local::now().hour();
+    if !in_active_hour(hour) {
+        return;
+    }
+    let water = match api.water_list().await {
+        Ok(w) => w,
+        Err(_) => return,
+    };
+    if !water_done_today(&water) {
+        let payload = PetStatePayload {
+            state: PetState::WaitingForInteraction,
+            reason: Some("drink_water".into()),
+            data: None,
+        };
+        let _ = app.emit("pet://state", payload);
+        let _ = app.emit(
+            "pet://bubble",
+            serde_json::json!({"text": "该喝水啦~点我确认完成!", "durationMs": 15000}),
+        );
+    }
+}
+
+/// 今天是否已有喝水记录(drunkAt 优先,回退 createdAt)
+fn water_done_today(records: &[WaterRecord]) -> bool {
+    let today = chrono::Local::now().date_naive();
+    records.iter().any(|r| {
+        r.drunk_at
+            .as_ref()
+            .or(r.created_at.as_ref())
+            .and_then(|d| d.to_local_date())
+            .map(|d| d == today)
+            .unwrap_or(false)
+    })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::api::models::FlexDate;
+
+    #[test]
+    fn active_hour_window_is_10_to_20_inclusive() {
+        assert!(!in_active_hour(9));
+        assert!(in_active_hour(10));
+        assert!(in_active_hour(15));
+        assert!(in_active_hour(20));
+        assert!(!in_active_hour(21));
+    }
+
+    fn water(drunk_at: Option<&str>, created_at: Option<&str>) -> WaterRecord {
+        WaterRecord {
+            id: None,
+            member_id: None,
+            checkin_id: None,
+            amount_ml: None,
+            water_type: None,
+            drunk_at: drunk_at.map(|s| FlexDate::Iso(s.to_string())),
+            created_at: created_at.map(|s| FlexDate::Iso(s.to_string())),
+        }
+    }
+
+    #[test]
+    fn water_done_today_matches_today_only() {
+        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
+        let today_dt = format!("{today}T08:00:00");
+        assert!(water_done_today(&[water(Some(&today_dt), None)]));
+        assert!(!water_done_today(&[water(Some("2000-01-01T08:00:00"), None)]));
+        assert!(!water_done_today(&[]));
+    }
+
+    #[test]
+    fn water_done_today_falls_back_to_created_at() {
+        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
+        let today_dt = format!("{today}T09:30:00");
+        assert!(water_done_today(&[water(None, Some(&today_dt))]));
+    }
+
+    fn task(id: i64, status: &str, due: Option<&str>) -> CfcTask {
+        CfcTask {
+            id,
+            title: None,
+            status: Some(status.to_string()),
+            deadline: due.map(|s| FlexDate::Iso(s.to_string())),
+        }
+    }
+
+    #[test]
+    fn pick_urgent_prefers_earliest_due_pending() {
+        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
+        let tasks = vec![
+            task(1, "COMPLETED", Some("2000-01-01")),
+            task(2, "PENDING", Some(&today)),
+            task(3, "PENDING", Some("2000-01-01")),
+        ];
+        assert_eq!(pick_urgent_task(&tasks).unwrap().id, 3);
+    }
+
+    #[test]
+    fn pick_urgent_none_when_not_yet_due() {
+        let tasks = vec![task(1, "PENDING", Some("2099-12-31"))];
+        assert!(pick_urgent_task(&tasks).is_none());
+    }
+
+    #[test]
+    fn pick_urgent_none_when_no_deadline() {
+        let tasks = vec![task(1, "PENDING", None)];
+        assert!(pick_urgent_task(&tasks).is_none());
+    }
+}

+ 108 - 0
cfc-desktop-pet/src-tauri/src/settings.rs

@@ -0,0 +1,108 @@
+use serde::{Deserialize, Serialize};
+use std::fs;
+use std::path::PathBuf;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct AppSettings {
+    #[serde(default = "default_base_url")]
+    pub base_url: String,
+    /// 自定义形象图片 URL(通过 /api/media/upload 上传后本地记录)
+    #[serde(default)]
+    pub custom_mascot_url: Option<String>,
+}
+
+impl Default for AppSettings {
+    fn default() -> Self {
+        Self { base_url: default_base_url(), custom_mascot_url: None }
+    }
+}
+
+fn default_base_url() -> String {
+    "https://cfc.cf-club.com".to_string()
+}
+
+pub struct SettingsStore {
+    path: PathBuf,
+}
+
+impl SettingsStore {
+    pub fn new(app_config_dir: PathBuf) -> Self {
+        Self { path: app_config_dir.join("settings.json") }
+    }
+
+    pub fn load(&self) -> AppSettings {
+        match fs::read_to_string(&self.path) {
+            Ok(txt) => serde_json::from_str(&txt).unwrap_or_default(),
+            Err(_) => AppSettings::default(),
+        }
+    }
+
+    pub fn save(&self, s: &AppSettings) -> Result<(), String> {
+        if let Some(dir) = self.path.parent() {
+            fs::create_dir_all(dir).map_err(|e| e.to_string())?;
+        }
+        let txt = serde_json::to_string_pretty(s).map_err(|e| e.to_string())?;
+        fs::write(&self.path, txt).map_err(|e| e.to_string())
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs;
+    use std::sync::atomic::{AtomicUsize, Ordering};
+
+    static COUNTER: AtomicUsize = AtomicUsize::new(0);
+
+    fn temp_dir() -> std::path::PathBuf {
+        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
+        let dir = std::env::temp_dir().join(format!("xafpet-settings-test-{}", n));
+        let _ = fs::remove_dir_all(&dir);
+        dir
+    }
+
+    #[test]
+    fn default_when_file_missing() {
+        let store = SettingsStore::new(temp_dir());
+        let cfg = store.load();
+        assert_eq!(cfg.base_url, "https://cfc.cf-club.com");
+    }
+
+    #[test]
+    fn default_when_file_malformed() {
+        let dir = temp_dir();
+        fs::create_dir_all(&dir).unwrap();
+        fs::write(dir.join("settings.json"), "not json").unwrap();
+        let store = SettingsStore::new(dir);
+        let cfg = store.load();
+        assert_eq!(cfg.base_url, "https://cfc.cf-club.com");
+    }
+
+    #[test]
+    fn roundtrip_save_load() {
+        let dir = temp_dir();
+        let store = SettingsStore::new(dir.clone());
+        let mut cfg = store.load();
+        cfg.base_url = "http://192.168.1.5:9082".into();
+        store.save(&cfg).unwrap();
+
+        let store2 = SettingsStore::new(dir.clone());
+        assert_eq!(store2.load().base_url, "http://192.168.1.5:9082");
+        let _ = fs::remove_dir_all(&dir);
+    }
+
+    #[test]
+    fn custom_mascot_url_roundtrip() {
+        let dir = temp_dir();
+        let store = SettingsStore::new(dir.clone());
+        let mut cfg = store.load();
+        assert!(cfg.custom_mascot_url.is_none());
+        cfg.custom_mascot_url = Some("https://cdn.example/x.png".into());
+        store.save(&cfg).unwrap();
+
+        let loaded = SettingsStore::new(dir.clone()).load();
+        assert_eq!(loaded.custom_mascot_url.as_deref(), Some("https://cdn.example/x.png"));
+        let _ = fs::remove_dir_all(&dir);
+    }
+}

+ 202 - 0
cfc-desktop-pet/src-tauri/src/state.rs

@@ -0,0 +1,202 @@
+use serde::Serialize;
+
+use crate::api::models::{CfcTask, EnergyOverview};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub enum PetState {
+    Idle,
+    Happy,
+    Busy,
+    Worried,
+    Excited,
+    Remind,
+    Energetic,
+    Tired,
+    Curious,
+    Sad,
+    Sleeping,
+    Watching,
+    WaitingForInteraction,
+    MiniGame,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct PetStatePayload {
+    pub state: PetState,
+    pub reason: Option<String>,
+    pub data: Option<serde_json::Value>,
+}
+
+/// 核心状态映射。优先级:Sleeping > Excited > Happy > Worried > Busy > Sad > Tired/Energetic/Curious > Idle
+pub fn map_state(
+    tasks: Option<&[CfcTask]>,
+    energy: Option<&EnergyOverview>,
+    water_done_today: bool,
+    user_inactive: bool,
+) -> PetState {
+    let has_pending = tasks
+        .map(|ts| {
+            ts.iter().any(|t| matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS")))
+        })
+        .unwrap_or(false);
+
+    if user_inactive && !has_pending {
+        return PetState::Sleeping;
+    }
+
+    if water_done_today {
+        return PetState::Excited;
+    }
+
+    if let Some(ts) = tasks {
+        if !ts.is_empty() {
+            if ts.iter().all(|t| t.status.as_deref() == Some("COMPLETED")) {
+                return PetState::Happy;
+            }
+            let today = chrono::Local::now().date_naive();
+            let overdue = ts.iter().any(|t| {
+                matches!(t.status.as_deref(), Some("PENDING") | Some("IN_PROGRESS"))
+                    && t.deadline
+                        .as_ref()
+                        .and_then(|d| d.to_local_date())
+                        .map(|due| due < today)
+                        .unwrap_or(false)
+            });
+            if overdue {
+                return PetState::Worried;
+            }
+            return PetState::Busy;
+        }
+    }
+
+    if let Some(e) = energy {
+        if !e.dimensions.is_empty() {
+            let all_low = e.dimensions.iter().all(|d| d.energy < 30);
+            if all_low {
+                return PetState::Sad;
+            }
+            let body = e.dimensions.iter().find(|d| d.code.as_deref() == Some("body"));
+            if let Some(b) = body {
+                if b.energy < 30 {
+                    return PetState::Tired;
+                }
+                if b.energy >= 85 {
+                    return PetState::Energetic;
+                }
+            }
+            let mind = e.dimensions.iter().find(|d| d.code.as_deref() == Some("wisdom"));
+            if let Some(m) = mind {
+                if m.energy >= 85 {
+                    return PetState::Curious;
+                }
+            }
+        }
+    }
+
+    PetState::Idle
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::api::models::{DimensionValue, FlexDate};
+
+    fn task(status: &str, due: Option<&str>) -> CfcTask {
+        CfcTask {
+            id: 1,
+            title: None,
+            status: Some(status.to_string()),
+            deadline: due.map(|s| FlexDate::Iso(s.to_string())),
+        }
+    }
+
+    fn energy(dims: &[(&str, i32)]) -> EnergyOverview {
+        EnergyOverview {
+            dimensions: dims
+                .iter()
+                .map(|(code, v)| DimensionValue {
+                    code: Some(code.to_string()),
+                    name: None,
+                    icon: None,
+                    element: None,
+                    energy: *v,
+                    health_index: 0,
+                })
+                .collect(),
+            total_energy: 0,
+            total_health_index: 0,
+        }
+    }
+
+    #[test]
+    fn sleeping_when_inactive_and_no_pending() {
+        assert_eq!(map_state(Some(&[]), None, false, true), PetState::Sleeping);
+    }
+
+    #[test]
+    fn not_sleeping_when_inactive_but_pending() {
+        let tasks = vec![task("PENDING", None)];
+        assert_eq!(map_state(Some(&tasks), None, false, true), PetState::Busy);
+    }
+
+    #[test]
+    fn excited_when_water_done_today() {
+        let tasks = vec![task("PENDING", None)];
+        assert_eq!(map_state(Some(&tasks), None, true, false), PetState::Excited);
+    }
+
+    #[test]
+    fn happy_when_all_completed() {
+        let tasks = vec![task("COMPLETED", None), task("COMPLETED", Some("2026-01-01"))];
+        assert_eq!(map_state(Some(&tasks), None, false, false), PetState::Happy);
+    }
+
+    #[test]
+    fn worried_when_overdue() {
+        let tasks = vec![task("PENDING", Some("2000-01-01"))];
+        assert_eq!(map_state(Some(&tasks), None, false, false), PetState::Worried);
+    }
+
+    #[test]
+    fn busy_when_in_progress_not_overdue() {
+        let tasks = vec![task("IN_PROGRESS", Some("2099-12-31"))];
+        assert_eq!(map_state(Some(&tasks), None, false, false), PetState::Busy);
+    }
+
+    #[test]
+    fn sad_when_all_dimensions_low() {
+        let e = energy(&[("body", 10), ("wisdom", 20), ("mind", 15)]);
+        assert_eq!(map_state(None, Some(&e), false, false), PetState::Sad);
+    }
+
+    #[test]
+    fn tired_when_body_low() {
+        let e = energy(&[("body", 20), ("wisdom", 90)]);
+        assert_eq!(map_state(None, Some(&e), false, false), PetState::Tired);
+    }
+
+    #[test]
+    fn energetic_when_body_high() {
+        let e = energy(&[("body", 90)]);
+        assert_eq!(map_state(None, Some(&e), false, false), PetState::Energetic);
+    }
+
+    #[test]
+    fn curious_when_mind_high() {
+        let e = energy(&[("body", 60), ("wisdom", 88)]);
+        assert_eq!(map_state(None, Some(&e), false, false), PetState::Curious);
+    }
+
+    #[test]
+    fn idle_when_nothing_qualifies() {
+        assert_eq!(map_state(None, None, false, false), PetState::Idle);
+    }
+
+    #[test]
+    fn state_serializes_to_camel_case() {
+        let json = serde_json::to_string(&PetState::WaitingForInteraction).unwrap();
+        assert_eq!(json, "\"waitingForInteraction\"");
+    }
+}

+ 71 - 0
cfc-desktop-pet/src-tauri/src/token.rs

@@ -0,0 +1,71 @@
+use keyring::Entry;
+
+/// 令牌存取:Windows → Credential Manager,macOS → Keychain
+pub struct TokenStore {
+    service: String,
+    account: String,
+}
+
+impl TokenStore {
+    pub fn new() -> Self {
+        Self::with_names("xafpet", "jwt")
+    }
+
+    pub fn with_names(service: &str, account: &str) -> Self {
+        Self { service: service.into(), account: account.into() }
+    }
+
+    pub fn save(&self, token: &str) -> Result<(), String> {
+        Entry::new(&self.service, &self.account)
+            .map_err(|e| e.to_string())?
+            .set_password(token)
+            .map_err(|e| e.to_string())
+    }
+
+    pub fn load(&self) -> Result<Option<String>, String> {
+        let entry = Entry::new(&self.service, &self.account).map_err(|e| e.to_string())?;
+        match entry.get_password() {
+            Ok(p) => Ok(Some(p)),
+            Err(keyring::Error::NoEntry) => Ok(None),
+            Err(e) => Err(e.to_string()),
+        }
+    }
+
+    pub fn clear(&self) -> Result<(), String> {
+        let entry = Entry::new(&self.service, &self.account).map_err(|e| e.to_string())?;
+        match entry.delete_credential() {
+            Ok(()) => Ok(()),
+            Err(keyring::Error::NoEntry) => Ok(()),
+            Err(e) => Err(e.to_string()),
+        }
+    }
+}
+
+impl Default for TokenStore {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[cfg(not(target_os = "macos"))]
+    #[test]
+    fn roundtrip_save_load_clear() {
+        let store = TokenStore::with_names("xafpet-test", "jwt");
+        let _ = store.clear();
+
+        assert_eq!(store.load().unwrap(), None);
+
+        store.save("tok-123").unwrap();
+        assert_eq!(store.load().unwrap(), Some("tok-123".into()));
+
+        store.save("tok-456").unwrap();
+        assert_eq!(store.load().unwrap(), Some("tok-456".into()));
+
+        store.clear().unwrap();
+        assert_eq!(store.load().unwrap(), None);
+    }
+}

+ 65 - 0
cfc-desktop-pet/src-tauri/src/tray.rs

@@ -0,0 +1,65 @@
+use crate::app_state::AppState;
+use tauri::menu::{MenuBuilder, MenuItemBuilder};
+use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
+use tauri::{App, Manager};
+
+pub fn setup_tray(app: &App) -> tauri::Result<()> {
+    let settings_i = MenuItemBuilder::with_id("settings", "设置").build(app)?;
+    let toggle_i = MenuItemBuilder::with_id("toggle", "显示/隐藏").build(app)?;
+    let passthrough_i = MenuItemBuilder::with_id("passthrough", "鼠标穿透").build(app)?;
+    let quit_i = MenuItemBuilder::with_id("quit", "退出").build(app)?;
+    let menu = MenuBuilder::new(app)
+        .items(&[&settings_i, &toggle_i, &passthrough_i, &quit_i])
+        .build()?;
+
+    let _tray = TrayIconBuilder::new()
+        .icon(app.default_window_icon().expect("bundle icon").clone())
+        .menu(&menu)
+        .show_menu_on_left_click(false)
+        .on_menu_event(|app, event| match event.id().as_ref() {
+            "settings" => show_settings(app),
+            "toggle" => toggle_main(app),
+            "passthrough" => toggle_passthrough(app),
+            "quit" => app.exit(0),
+            _ => {}
+        })
+        .on_tray_icon_event(|tray, event| {
+            if let TrayIconEvent::Click {
+                button: MouseButton::Left,
+                button_state: MouseButtonState::Up,
+                ..
+            } = event
+            {
+                toggle_main(tray.app_handle());
+            }
+        })
+        .build(app)?;
+
+    Ok(())
+}
+
+pub fn show_settings(app: &tauri::AppHandle) {
+    if let Some(win) = app.get_webview_window("settings") {
+        let _ = win.show();
+        let _ = win.set_focus();
+    }
+}
+
+pub fn toggle_main(app: &tauri::AppHandle) {
+    if let Some(win) = app.get_webview_window("main") {
+        match win.is_visible() {
+            Ok(true) => { let _ = win.hide(); }
+            _ => { let _ = win.show(); }
+        }
+    }
+}
+
+pub fn toggle_passthrough(app: &tauri::AppHandle) {
+    if let Some(state) = app.try_state::<AppState>() {
+        let mut passthrough = state.passthrough.lock().unwrap();
+        *passthrough = !*passthrough;
+        if let Some(win) = app.get_webview_window("main") {
+            let _ = win.set_ignore_cursor_events(*passthrough);
+        }
+    }
+}

+ 50 - 0
cfc-desktop-pet/src-tauri/tauri.conf.json

@@ -0,0 +1,50 @@
+{
+  "$schema": "https://schema.tauri.app/config/2",
+  "productName": "XafPet",
+  "version": "0.1.0",
+  "identifier": "com.etotem.xafpet",
+  "build": {
+    "frontendDist": "../src"
+  },
+  "app": {
+    "withGlobalTauri": true,
+    "macOSPrivateApi": true,
+    "windows": [
+      {
+        "label": "main",
+        "title": "浠艾福桌面宠物",
+        "width": 200,
+        "height": 200,
+        "transparent": true,
+        "decorations": false,
+        "shadow": false,
+        "alwaysOnTop": true,
+        "skipTaskbar": false,
+        "resizable": false
+      },
+      {
+        "label": "settings",
+        "title": "设置",
+        "url": "settings.html",
+        "width": 360,
+        "height": 640,
+        "resizable": false,
+        "visible": false
+      }
+    ],
+    "security": {
+      "csp": null
+    }
+  },
+  "bundle": {
+    "active": true,
+    "targets": "all",
+    "icon": [
+      "icons/32x32.png",
+      "icons/128x128.png",
+      "icons/128x128@2x.png",
+      "icons/icon.icns",
+      "icons/icon.ico"
+    ]
+  }
+}

+ 27 - 0
cfc-desktop-pet/src/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/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
+  }
+}

+ 106 - 0
cfc-desktop-pet/src/animation-gen.js

@@ -0,0 +1,106 @@
+// 程序化动作帧生成器(provider 无关,本地 canvas 变换,无需 API Key)。
+// 依据基础形象为每个动作生成 8-16 帧序列(PNG data URL),供 pet-engine.js 的 playClip 播放。
+// 数据源必须是 data: URL 或同源资源;若为跨域远程图,canvas 会被污染导致 toDataURL 抛错,
+// 此时 Promise reject,前端静默回退 CSS 动画。
+(function () {
+  'use strict';
+
+  var MAX_DIM = 256;
+
+  // 每个状态 → 一段随时间 t∈[0,1) 的仿射变换。
+  // dx/dy: 相对宽/高的位移比例;rot: 弧度;sx/sy: 缩放。
+  var PRESETS = {
+    idle:                  { frames: 12, fps: 8,  loop: true,  tf: function (t) { var s = Math.sin(2 * Math.PI * t); return { dy: -0.015 * s, sx: 1 - 0.010 * s, sy: 1 + 0.020 * s }; } },
+    happy:                 { frames: 12, fps: 14, loop: true,  tf: function (t) { return { dy: -0.060 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.030 * Math.sin(2 * Math.PI * t) }; } },
+    excited:               { frames: 14, fps: 16, loop: true,  tf: function (t) { return { dy: -0.080 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.050 * Math.sin(2 * Math.PI * t) }; } },
+    celebrate:             { frames: 16, fps: 16, loop: true,  tf: function (t) { return { dy: -0.100 * Math.abs(Math.sin(Math.PI * t)), rot: 0.080 * Math.sin(2 * Math.PI * t) }; } },
+    busy:                  { frames: 8,  fps: 16, loop: true,  tf: function (t) { return { dx: 0.012 * Math.sin(4 * Math.PI * t), rot: 0.020 * Math.sin(4 * Math.PI * t) }; } },
+    miniGame:              { frames: 12, fps: 14, loop: true,  tf: function (t) { return { dy: -0.060 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.050 * Math.sin(4 * Math.PI * t) }; } },
+    waitingForInteraction: { frames: 14, fps: 10, loop: true,  tf: function (t) { return { rot: 0.050 * Math.sin(2 * Math.PI * t), dx: 0.010 * Math.sin(2 * Math.PI * t) }; } },
+    remind:                { frames: 10, fps: 8,  loop: true,  tf: function (t) { return { rot: 0.060 * Math.sin(4 * Math.PI * t) }; } },
+    sleeping:              { frames: 16, fps: 6,  loop: true,  tf: function (t) { return { rot: 0.040, sy: 1 + 0.025 * Math.sin(2 * Math.PI * t), dy: 0.010 * Math.sin(2 * Math.PI * t) }; } },
+    worried:               { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { dy: 0.015, sy: 1 - 0.020 * Math.abs(Math.sin(Math.PI * t)), rot: -0.030 * Math.sin(2 * Math.PI * t) }; } },
+    sad:                   { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { dy: 0.015, sy: 1 - 0.020 * Math.abs(Math.sin(Math.PI * t)), rot: -0.030 * Math.sin(2 * Math.PI * t) }; } },
+    tired:                 { frames: 12, fps: 5,  loop: true,  tf: function (t) { return { dy: 0.020, rot: -0.020 * Math.sin(2 * Math.PI * t) }; } },
+    energetic:             { frames: 14, fps: 18, loop: true,  tf: function (t) { return { dy: -0.070 * Math.abs(Math.sin(2 * Math.PI * t)), rot: 0.040 * Math.sin(2 * Math.PI * t) }; } },
+    curious:               { frames: 12, fps: 8,  loop: true,  tf: function (t) { return { rot: 0.070 * Math.sin(2 * Math.PI * t), dy: -0.020 * Math.sin(2 * Math.PI * t) }; } },
+    watching:              { frames: 12, fps: 6,  loop: true,  tf: function (t) { return { rot: 0.030 * Math.sin(2 * Math.PI * t), dy: -0.010 * Math.abs(Math.sin(2 * Math.PI * t)) }; } },
+    custom:                { frames: 12, fps: 8,  loop: true,  tf: function (t) { var s = Math.sin(2 * Math.PI * t); return { dy: -0.015 * s }; } }
+  };
+
+  var KNOWN_STATES = ['idle', 'happy', 'busy', 'worried', 'excited', 'sad', 'tired',
+    'remind', 'energetic', 'curious', 'watching', 'waitingForInteraction', 'miniGame',
+    'sleeping', 'celebrate', 'custom'];
+
+  function presetFor(state) { return PRESETS[state] || PRESETS.idle; }
+
+  function loadImage(src) {
+    return new Promise(function (resolve, reject) {
+      var img = new Image();
+      img.onload = function () { resolve(img); };
+      img.onerror = function () { reject(new Error('image load failed')); };
+      img.src = src;
+    });
+  }
+
+  function makeClip(img, preset) {
+    var iw = img.naturalWidth || img.width || 120;
+    var ih = img.naturalHeight || img.height || 140;
+    var scale = Math.min(1, MAX_DIM / Math.max(iw, ih));
+    var cw = Math.max(1, Math.round(iw * scale));
+    var ch = Math.max(1, Math.round(ih * scale));
+    var canvas = document.createElement('canvas');
+    canvas.width = cw;
+    canvas.height = ch;
+    var ctx = canvas.getContext('2d');
+    var frames = [];
+    for (var i = 0; i < preset.frames; i++) {
+      var tr = preset.tf(i / preset.frames) || {};
+      ctx.clearRect(0, 0, cw, ch);
+      ctx.save();
+      ctx.translate(cw / 2 + (tr.dx || 0) * cw, ch / 2 + (tr.dy || 0) * ch);
+      if (tr.rot) ctx.rotate(tr.rot);
+      ctx.scale(tr.sx || 1, tr.sy || 1);
+      ctx.drawImage(img, -cw / 2, -ch / 2, cw, ch);
+      ctx.restore();
+      frames.push(canvas.toDataURL('image/png'));
+    }
+    return {
+      frames: frames,
+      sprite: null,
+      frame_width: cw,
+      frame_height: ch,
+      count: frames.length,
+      fps: preset.fps,
+      loop: preset.loop
+    };
+  }
+
+  // 依据 config.actions(状态→动作名)为每个动作生成剪辑,按动作名为键。
+  // 返回 Promise<{ [actionName]: ActionClip }>;canvas 被污染时 reject。
+  function generateForConfig(config, src) {
+    var actions = (config && config.actions) || {};
+    var map = {}; // actionName -> state
+    KNOWN_STATES.forEach(function (s) {
+      var a = actions[s] || s;
+      if (!(a in map)) map[a] = s;
+    });
+    Object.keys(actions).forEach(function (s) {
+      var a = actions[s];
+      if (a && !(a in map)) map[a] = s;
+    });
+
+    return loadImage(src).then(function (img) {
+      var cache = {}; // state -> clip
+      var out = {};
+      Object.keys(map).forEach(function (actionName) {
+        var state = map[actionName];
+        var clip = cache[state] || (cache[state] = makeClip(img, presetFor(state)));
+        out[actionName] = clip;
+      });
+      return out;
+    });
+  }
+
+  window.cfcPetGen = { generateForConfig: generateForConfig, presets: PRESETS };
+})();

+ 46 - 0
cfc-desktop-pet/src/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; }

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

@@ -0,0 +1,65 @@
+(function () {
+  'use strict';
+
+  var invoke = window.__TAURI__.core.invoke;
+  var listen = window.__TAURI__.event.listen;
+
+  // ── Rust → JS ──────────────────────────────
+  listen('pet://state', function (e) {
+    if (window.cfcPetAPI) window.cfcPetAPI.notifyState(e.payload);
+  });
+  listen('pet://bubble', function (e) {
+    if (window.cfcPetAPI) window.cfcPetAPI.showBubble(e.payload.text, e.payload.durationMs);
+  });
+  listen('pet://config', function (e) {
+    if (window.cfcPetAPI) window.cfcPetAPI.loadConfig(e.payload);
+  });
+  function applyCustomImage(url, dataUrl) {
+    if (!window.cfcPetAPI) return;
+    if (!dataUrl && url && /^https?:/i.test(url)) {
+      // 远程图片:先取 data URL(规避 canvas 跨域污染)再生成动作;失败则仍显示原图
+      try {
+        invoke('data_url_from_url', { url: url }).then(function (d) {
+          window.cfcPetAPI.setCustomImage(url, d);
+        }).catch(function () {
+          window.cfcPetAPI.setCustomImage(url, null);
+        });
+      } catch (_) {
+        window.cfcPetAPI.setCustomImage(url, null);
+      }
+    } else {
+      window.cfcPetAPI.setCustomImage(url, dataUrl);
+    }
+  }
+
+  listen('pet://custom-image', function (e) {
+    applyCustomImage(e.payload && e.payload.url, e.payload && e.payload.dataUrl);
+  });
+
+  // 启动时应用已保存的自定义形象
+  try {
+    invoke('get_settings').then(function (s) {
+      window.__petHasToken = !!(s && s.hasToken);
+      if (s && s.customMascotUrl) {
+        applyCustomImage(s.customMascotUrl, null);
+      }
+    }).catch(function () {});
+  } catch (_) { /* noop */ }
+
+  // ── JS → Rust(替换 chrome.webview.hostObjects.sync.cfcHost)──
+  window.cfcHost = {
+    onInteract: function (action, dataJson) {
+      try {
+        invoke('on_interact', { action: action, data: dataJson });
+      } catch (err) {
+        console.log('[interaction]', action, dataJson);
+      }
+    },
+    onDragStart: function () {
+      try { invoke('drag_start'); } catch (e) { /* noop */ }
+    },
+    onDragEnd: function () {
+      try { invoke('drag_end'); } catch (e) { /* noop */ }
+    }
+  };
+})();

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

@@ -0,0 +1,70 @@
+<!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 !important;
+    -webkit-app-region: no-drag;
+  }
+  #pet-stage {
+    position: fixed; inset: 0; display: flex; align-items: flex-end;
+    justify-content: center; pointer-events: none;
+    background: transparent !important;
+  }
+  #pet {
+    width: 120px; height: 140px; pointer-events: auto;
+    cursor: grab; user-select: none;
+    -webkit-app-region: no-drag;
+  }
+  #pet img { width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
+  #bubble {
+    position: absolute; bottom: 150px; left: 50%; transform: translateX(-50%);
+    background: rgba(255,255,255,0.95); 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);
+    pointer-events: none;
+  }
+  #bubble::after {
+    content: ''; position: absolute; top: 100%; left: 50%;
+    transform: translateX(-50%);
+    border: 6px solid transparent; border-top-color: #4A9BD7;
+  }
+  /* 右键菜单 */
+  #context-menu {
+    position: absolute; display: none;
+    background: #fff; border: 1px solid #ccc; border-radius: 6px;
+    box-shadow: 0 2px 12px rgba(0,0,0,.15); padding: 4px 0;
+    min-width: 120px; z-index: 9999; pointer-events: auto;
+  }
+  #context-menu .item {
+    padding: 6px 16px; font-size: 13px; cursor: pointer; color: #333;
+  }
+  #context-menu .item:hover { background: #f0f6ff; }
+  #context-menu .divider { height: 1px; background: #e0e0e0; margin: 4px 0; }
+</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>
+<!-- 右键菜单 -->
+<div id="context-menu">
+  <div class="item" data-action="settings">设置</div>
+  <div class="item" data-action="passthrough">鼠标穿透</div>
+  <div class="divider"></div>
+  <div class="item" data-action="quit">退出</div>
+</div>
+<script src="animation-gen.js"></script>
+<script src="pet-engine.js"></script>
+<script src="bridge.js"></script>
+<script src="interactions.js"></script>
+</body>
+</html>

+ 93 - 0
cfc-desktop-pet/src/interactions.js

@@ -0,0 +1,93 @@
+(function () {
+  'use strict';
+
+  const petEl = document.getElementById('pet');
+  const ctxMenu = document.getElementById('context-menu');
+  const invoke = window.__TAURI__?.core?.invoke;
+
+  let dragStartX = 0, dragStartY = 0;
+  let pressTimer = null;
+  let isDragging = false;
+
+  // ── 右键菜单 ──────────────────────────────
+  petEl.addEventListener('contextmenu', function (e) {
+    e.preventDefault();
+    const x = Math.min(e.clientX, window.innerWidth - 130);
+    const y = Math.min(e.clientY, window.innerHeight - 100);
+    ctxMenu.style.left = x + 'px';
+    ctxMenu.style.top = y + 'px';
+    ctxMenu.style.display = 'block';
+  });
+
+  document.addEventListener('click', function (e) {
+    if (!ctxMenu.contains(e.target)) ctxMenu.style.display = 'none';
+  });
+  document.addEventListener('keydown', function (e) {
+    if (e.key === 'Escape') ctxMenu.style.display = 'none';
+  });
+
+ctxMenu.addEventListener('click', function (e) {
+    const item = e.target.closest('.item');
+    if (!item) return;
+    const action = item.dataset.action;
+    ctxMenu.style.display = 'none';
+    if (action === 'settings') invoke?.('open_settings');
+    else if (action === 'quit') { try { invoke?.('quit_app'); } catch (_) { window.close(); } }
+});
+
+  // ── 点击 / 长按 / 拖拽 ───────────────────
+  petEl.addEventListener('mousedown', function (e) {
+    dragStartX = e.clientX;
+    dragStartY = e.clientY;
+    isDragging = false;
+    pressTimer = setTimeout(() => {
+      localReact('pet');
+      if (backendEnabled()) 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) {
+      localReact('click');
+      // 未登录 / 离线时不打后端,避免只剩「打卡失败」噪音,仅保留本地互动反馈。
+      if (backendEnabled()) notifyInteract('click', {});
+    } else {
+      notifyDragEnd();
+    }
+  });
+
+  // 本地即时反馈:不依赖后端也能看到宠物对点击的响应。
+  function localReact(kind) {
+    try { if (window.cfcPetAPI && window.cfcPetAPI.react) window.cfcPetAPI.react(kind); } catch (_) { /* noop */ }
+  }
+
+  // 是否已连后端(有令牌)。默认允许,bridge 拉到设置后按 hasToken 修正。
+  function backendEnabled() {
+    return window.__petHasToken !== false;
+  }
+
+  function notifyInteract(action, data) {
+    try {
+      window.cfcHost.onInteract(action, JSON.stringify(data));
+    } catch (err) { console.log('[interaction]', action, data); }
+  }
+
+  function notifyDragStart() {
+    try { window.cfcHost.onDragStart(); } catch (e) { /* noop */ }
+  }
+
+  function notifyDragEnd() {
+    try { window.cfcHost.onDragEnd(); } catch (e) { /* noop */ }
+  }
+})();

+ 277 - 0
cfc-desktop-pet/src/pet-engine.js

@@ -0,0 +1,277 @@
+(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 lastConfig = null;
+  let customImage = null;
+  let customDataUrl = null;
+  let runtimeAnimations = {};
+  let animSig = null;
+  let animPending = false;
+  let bubbleTimer = null;
+  let frameTimer = null;
+  let frameIdx = 0;
+
+  const DEFAULT_IMAGE = 'data:image/svg+xml;utf8,' +
+    encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="120" height="140">' +
+      '<text x="60" y="100" font-size="90" text-anchor="middle">🦜</text></svg>');
+
+  // 离线兜底配置:未连后端(未登录 / 服务不可达)时也要有「状态→动作」映射,
+  // 否则 setState 会退化成 .anim-<state> 而动画样式并不存在 → 宠物静止。
+  const FALLBACK_CONFIG = {
+    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'
+    }
+  };
+
+  // 服务端状态超过该时长未到达即视为离线,进入「自主活动」模式。
+  const SERVER_TRUST_MS = 90000;
+  const LIFE_TICK_MS = 2800;
+  const LIFE_CYCLE = ['idle', 'idle', 'busy', 'idle', 'happy', 'idle',
+    'waitingForInteraction', 'idle', 'sleeping', 'idle', 'celebrate'];
+  const LIFE_BUBBLES = ['在想什么呢~', '陪我玩一会儿吧!', '今天也要加油哦~', '嗯…休息一下~', '你好呀!'];
+  let lastServerStateAt = 0;
+  let lifeIdx = 0;
+
+  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;
+      lastServerStateAt = Date.now();
+      lifeIdx = 0;
+      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);
+    },
+    setCustomImage: function (url, dataUrl) {
+      customImage = url || null;
+      customDataUrl = dataUrl || null;
+      stopClip();
+      if (customImage) {
+        petImg.src = customImage;
+        petImg.onload = function () { ensureAnimations(); };
+        petImg.onerror = function () { petImg.src = DEFAULT_IMAGE; };
+      } else if (lastConfig) {
+        applyImage(lastConfig);
+      } else {
+        petImg.src = DEFAULT_IMAGE;
+        ensureAnimations();
+      }
+    },
+    showBubble: function (text, durationMs) {
+      bubble.textContent = text;
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      if (durationMs) bubbleTimer = setTimeout(hideBubble, durationMs);
+    },
+    hideBubble: hideBubble,
+    react: react,
+    getState: function () { return currentState; }
+  };
+
+  function hideBubble() { bubble.style.display = 'none'; }
+
+  function applyImage(cfg) {
+    if (!cfg) return;
+    stopClip();
+    lastConfig = cfg;
+    const src = customImage || (cfg.default_image ? cfg.image_base_url + cfg.default_image : null);
+    if (!src) { petImg.src = DEFAULT_IMAGE; ensureAnimations(); return; }
+    petImg.src = src;
+    petImg.onload = function () { ensureAnimations(); };
+    petImg.onerror = function () {
+      petImg.src = DEFAULT_IMAGE;
+      petImg.onload = function () { ensureAnimations(); };
+    };
+  }
+
+  function setState(stateName, reason, data) {
+    currentState = stateName;
+    if (currentAnimation) { petEl.classList.remove(currentAnimation); currentAnimation = null; }
+
+    const actionName = (config && config.actions && config.actions[stateName]) || stateName;
+    const clipCfg = (config && config.animations && config.animations[actionName])
+      || runtimeAnimations[actionName]
+      || null;
+
+    if (clipCfg && clipCfg.frames && clipCfg.frames.length) {
+      playClip(clipCfg);
+    } else {
+      stopClip();
+      applyImage(lastConfig);
+      const animClass = 'anim-' + actionName;
+      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 (reason !== 'life' && BUBBLE_TEXTS[stateName]) {
+      bubble.textContent = BUBBLE_TEXTS[stateName];
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 5000);
+    }
+  }
+
+  function baseSrc() {
+    if (customImage) return customImage;
+    if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image;
+    return DEFAULT_IMAGE;
+  }
+
+  // 生成动作帧所用的基础形象:优先用 data URL(避免 canvas 跨域污染)。
+  function generationSrc() {
+    if (customImage) return customDataUrl || customImage;
+    if (lastConfig && lastConfig.default_image) return lastConfig.image_base_url + lastConfig.default_image;
+    return DEFAULT_IMAGE;
+  }
+
+  // 本地程序化生成动作帧(provider 无关、无需 API Key)。
+  // 成功后重新应用当前状态,使生成的动作立即生效。
+  // 以「基础形象 + 动作映射」为签名,同一形象下不重复生成。
+  function ensureAnimations() {
+    if (!window.cfcPetGen) return;
+    const src = generationSrc();
+    if (!src) return;
+    const cfg = lastConfig || { actions: {} };
+    const sig = src + '|' + JSON.stringify(cfg.actions || {});
+    if (animSig === sig && Object.keys(runtimeAnimations).length) return;
+    if (animPending) return;
+    animPending = true;
+    window.cfcPetGen.generateForConfig(cfg, src).then(function (clips) {
+      runtimeAnimations = clips || {};
+      animSig = sig;
+      animPending = false;
+      setState(currentState, null, null);
+    }).catch(function () {
+      animPending = false;
+      // 配置形象缺失(如默认图不存在)时,退回内置形象再生成一次,保证离线也有帧动画。
+      if (src !== DEFAULT_IMAGE) {
+        animPending = true;
+        window.cfcPetGen.generateForConfig(cfg, DEFAULT_IMAGE).then(function (clips) {
+          runtimeAnimations = clips || {};
+          animSig = sig + '|fallback';
+          animPending = false;
+          setState(currentState, null, null);
+        }).catch(function () { animPending = false; });
+      }
+    });
+  }
+
+  function stopClip() {
+    if (frameTimer) { clearInterval(frameTimer); frameTimer = null; }
+  }
+
+  // 播放帧序列剪辑(provider 无关)。loop:false 播完回到基础形象。
+  function playClip(clip) {
+    stopClip();
+    const base = (lastConfig && lastConfig.image_base_url) || '';
+    const frames = clip.frames.map(function (f) {
+      return /^(https?:|data:|\/)/.test(f) ? f : base + f;
+    });
+    const fps = clip.fps > 0 ? clip.fps : 12;
+    frameIdx = 0;
+    petImg.onerror = null;
+    petImg.src = frames[0];
+    frameTimer = setInterval(function () {
+      frameIdx++;
+      if (frameIdx >= frames.length) {
+        if (clip.loop === false) {
+          stopClip();
+          petImg.src = baseSrc();
+          return;
+        }
+        frameIdx = 0;
+      }
+      petImg.src = frames[frameIdx];
+    }, 1000 / fps);
+  }
+
+  // 本地即时互动反馈(不依赖后端,也不改变当前状态):点击/抚摸时立即给出动作。
+  function react(kind) {
+    const cls = kind === 'pet' ? 'anim-clap' : 'anim-pet_touch';
+    const ms = kind === 'pet' ? 1900 : 700;
+    petEl.classList.remove(cls);
+    void petEl.offsetWidth; // 强制回流,确保动画可重播
+    petEl.classList.add(cls);
+    setTimeout(function () { petEl.classList.remove(cls); }, ms);
+    if (kind === 'pet') {
+      bubble.textContent = '好痒~嘻嘻!';
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 2000);
+    }
+  }
+
+  // 自主活动循环:服务端状态长时间未到达(离线 / 未登录)时,按节奏切换轻量动作,
+  // 让宠物始终保持「活着」的互动感;服务端状态恢复后自动让位。
+  setInterval(function () {
+    if (Date.now() - lastServerStateAt <= SERVER_TRUST_MS) { lifeIdx = 0; return; }
+    lifeIdx = (lifeIdx + 1) % LIFE_CYCLE.length;
+    const st = LIFE_CYCLE[lifeIdx];
+    setState(st, 'life', null);
+    if (st !== 'idle' && Math.random() < 0.5) {
+      bubble.textContent = LIFE_BUBBLES[Math.floor(Math.random() * LIFE_BUBBLES.length)];
+      bubble.style.display = 'block';
+      clearTimeout(bubbleTimer);
+      bubbleTimer = setTimeout(hideBubble, 3000);
+    }
+  }, LIFE_TICK_MS);
+
+  // 启动即以兜底配置呈现,确保离线也具备「状态→动作」映射;服务端配置到达后覆盖。
+  config = FALLBACK_CONFIG;
+  lastConfig = FALLBACK_CONFIG;
+  petImg.src = DEFAULT_IMAGE;
+  petImg.onload = function () { ensureAnimations(); };
+
+  setInterval(function () {
+    if (document.getElementById('pet-img')) {
+      petImg.classList.add('anim-blink_eye');
+      setTimeout(() => petImg.classList.remove('anim-blink_eye'), 200);
+    }
+  }, 3500);
+})();

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

@@ -0,0 +1,183 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8" />
+<title>设置</title>
+<style>
+  body { font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; margin: 16px; }
+  label { display: block; margin-bottom: 4px; font-size: 13px; color: #555; }
+  input { width: 100%; box-sizing: border-box; padding: 6px 8px; margin-bottom: 12px;
+          border: 1px solid #ccc; border-radius: 6px; font-size: 13px; }
+  button { width: 100%; padding: 8px; border: none; border-radius: 6px;
+           background: #4A9BD7; color: #fff; font-size: 14px; cursor: pointer; }
+  button.secondary { background: #9aa4ad; margin-top: 8px; }
+  button:disabled { opacity: .6; cursor: default; }
+  .hint { font-size: 12px; color: #999; margin: -8px 0 8px; }
+  #mascot-preview { width: 72px; height: 72px; object-fit: contain;
+                    border: 1px dashed #ccc; border-radius: 8px; display: none; margin-bottom: 8px; }
+  hr { border: none; border-top: 1px solid #eee; margin: 14px 0; }
+  #status { margin-top: 10px; font-size: 12px; color: #999; }
+  #wechat-qr { display: none; width: 180px; height: 180px; margin: 8px auto 0; }
+</style>
+</head>
+<body>
+  <label for="base-url">后端地址</label>
+  <input id="base-url" placeholder="https://cfc.cf-club.com" />
+  <label for="token">令牌(留空则不修改)</label>
+  <input id="token" type="password" placeholder="JWT Token" />
+  <button id="save">保存</button>
+
+  <hr />
+
+  <label>宠物形象</label>
+  <img id="mascot-preview" alt="preview" />
+  <input id="mascot-file" type="file" accept="image/png,image/jpeg,image/webp" />
+  <div class="hint">支持 PNG / JPG / WebP,建议正方形、≤2MB</div>
+  <button id="upload-mascot" disabled>上传并使用此形象</button>
+  <button id="clear-mascot" class="secondary">恢复默认形象</button>
+
+  <hr />
+
+  <label>微信登录</label>
+  <button id="wechat-login">微信扫码登录</button>
+  <div class="hint">点击后出现小程序码,用微信扫码并在小程序内确认,即可自动与小程序账号同步登录(无需手动填令牌)</div>
+  <img id="wechat-qr" alt="小程序码" />
+
+  <div id="status"></div>
+  <script>
+    (function () {
+      'use strict';
+      var invoke = window.__TAURI__.core.invoke;
+      var baseUrlEl = document.getElementById('base-url');
+      var tokenEl = document.getElementById('token');
+      var statusEl = document.getElementById('status');
+      var fileEl = document.getElementById('mascot-file');
+      var previewEl = document.getElementById('mascot-preview');
+      var uploadBtn = document.getElementById('upload-mascot');
+      var clearBtn = document.getElementById('clear-mascot');
+
+      function setStatus(msg) { statusEl.textContent = msg || ''; }
+
+      invoke('get_settings').then(function (s) {
+        baseUrlEl.value = s.baseUrl || 'https://cfc.cf-club.com';
+        if (s.customMascotUrl) {
+          previewEl.src = s.customMascotUrl;
+          previewEl.style.display = 'block';
+        }
+        setStatus(s.hasToken ? '已保存令牌' : '未设置令牌');
+      }).catch(function () {});
+
+      document.getElementById('save').addEventListener('click', function () {
+        var baseUrl = baseUrlEl.value.trim();
+        var token = tokenEl.value.trim();
+        invoke('save_settings', { baseUrl: baseUrl, token: token ? token : null })
+          .then(function () {
+            setStatus('已保存,轮询已重启');
+            setTimeout(function () { setStatus(''); }, 2500);
+          })
+          .catch(function (e) { setStatus('保存失败:' + e); });
+      });
+
+      fileEl.addEventListener('change', function () {
+        var file = fileEl.files && fileEl.files[0];
+        uploadBtn.disabled = !file;
+        if (!file) { previewEl.style.display = 'none'; return; }
+        var reader = new FileReader();
+        reader.onload = function () {
+          previewEl.src = reader.result;
+          previewEl.style.display = 'block';
+        };
+        reader.readAsDataURL(file);
+      });
+
+      uploadBtn.addEventListener('click', function () {
+        var file = fileEl.files && fileEl.files[0];
+        if (!file) return;
+        if (file.size > 10 * 1024 * 1024) { setStatus('图片过大(上限 10MB)'); return; }
+        uploadBtn.disabled = true;
+        setStatus('上传中…');
+        var reader = new FileReader();
+        reader.onload = function () {
+          var result = String(reader.result);
+          var comma = result.indexOf(',');
+          var meta = result.substring(0, comma);
+          var base64 = result.substring(comma + 1);
+          var mime = meta.indexOf(':') >= 0 && meta.indexOf(';') > 0
+            ? meta.substring(meta.indexOf(':') + 1, meta.indexOf(';')) : '';
+          invoke('upload_mascot', {
+            filename: file.name,
+            mime: mime,
+            dataBase64: base64
+          }).then(function () {
+            setStatus('已上传并使用该形象');
+            uploadBtn.disabled = false;
+            setTimeout(function () { setStatus(''); }, 3000);
+          }).catch(function (e) {
+            setStatus('上传失败:' + e);
+            uploadBtn.disabled = false;
+          });
+        };
+        reader.onerror = function () { setStatus('读取图片失败'); uploadBtn.disabled = false; };
+        reader.readAsDataURL(file);
+      });
+
+      clearBtn.addEventListener('click', function () {
+        invoke('clear_custom_mascot').then(function () {
+          fileEl.value = '';
+          previewEl.style.display = 'none';
+          uploadBtn.disabled = true;
+          setStatus('已恢复默认形象');
+          setTimeout(function () { setStatus(''); }, 2500);
+        }).catch(function (e) { setStatus('操作失败:' + e); });
+      });
+
+      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-qr', function (ev) {
+          var p = (ev && ev.payload) || {};
+          var img = document.getElementById('wechat-qr');
+          if (p.qrImage) {
+            img.src = p.qrImage;
+            img.style.display = 'block';
+            setStatus('请用微信扫码并在小程序内确认');
+          } else {
+            img.style.display = 'none';
+            setStatus('(测试模式)已生成登录状态:请在已登录的小程序内访问扫码登录接口确认');
+          }
+        });
+      } catch (e) { /* event API 不可用时忽略 */ }
+
+      // 后台轮询任务完成后由 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 = '';
+            document.getElementById('wechat-qr').style.display = 'none';
+          } else if (p.status === 'pending') {
+            setStatus('等待扫码…');
+          } else if (p.status === 'timeout') {
+            setStatus('登录超时,请重试');
+            document.getElementById('wechat-qr').style.display = 'none';
+          } else if (p.status === 'notfound') {
+            setStatus('该微信未绑定小程序账号');
+          } else if (p.status === 'expired') {
+            setStatus('二维码已过期,请重试');
+            document.getElementById('wechat-qr').style.display = 'none';
+          } else {
+            setStatus('登录失败:' + (p.message || p.status));
+          }
+        });
+      } catch (e) { /* event API 不可用时忽略 */ }
+    })();
+  </script>
+</body>
+</html>

+ 0 - 0
cfc-frontend/pages/family/components/GenerationPicker.vue → cfc-frontend/components/GenerationPicker.vue


+ 3 - 1
cfc-frontend/components/LoginGuideCard.vue

@@ -26,7 +26,9 @@ export default {
   },
   methods: {
     goLogin: function() {
-      uni.navigateTo({ url: '/pages/login/login' })
+      // 走全局登录浮层(mixin 会沿 $parent 链找到宿主页面的 <login-sheet>);
+      // 宿主页面未挂浮层时 mixin 自动回退整页登录
+      this.openLoginSheet()
     }
   }
 }

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

+ 0 - 0
cfc-frontend/components/login-sheet.vue


+ 0 - 0
cfc-frontend/mixins/login-sheet.js


+ 14 - 1
cfc-frontend/pages.json

@@ -486,6 +486,13 @@
             "navigationBarTitleText": "DAN 测评报告",
             "navigationStyle": "custom"
           }
+        },
+        {
+          "path": "comparison",
+          "style": {
+            "navigationBarTitleText": "DAN 报告对比",
+            "navigationStyle": "custom"
+          }
         }
       ]
     },
@@ -1774,6 +1781,12 @@
             "navigationBarTitleText": "登录",
             "navigationStyle": "custom"
           }
+        },
+        {
+          "path": "scan-login",
+          "style": {
+            "navigationBarTitleText": "扫码登录"
+          }
         }
       ]
     },
@@ -1824,7 +1837,7 @@
         {
           "path": "message-history",
           "style": {
-            "navigationBarTitleText": "消息历史"
+            "navigationBarTitleText": ""
           }
         }
       ]

+ 26 - 5
cfc-frontend/pages/action-detail/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- Tabbar 切换过渡页 -->
     <tab-transition v-if="showTabTransition" dimCode="action" ref="tabTransition" @close="showTabTransition = false" />
 
@@ -27,6 +29,20 @@
       <text class="guidance-text">{{ monthlyGuidance }}</text>
     </view>
 
+    <!-- 游客价值卡(统一组件:维度色 CTA + 价值预览) -->
+    <DimensionVisitorCard
+      v-if="!isLoggedIn"
+      dimCode="action"
+      card-title="行维度 · 关系滋养"
+      :benefits="[
+        '家庭关系图谱与关键人分析',
+        '章鱼图 / 珍珠图能力资产',
+        '家庭关系质量与改善建议'
+      ]"
+      cta-text="登录绘制关系网络"
+      secondary-text="了解关系维度"
+      secondary-url="/pages/action-detail/dimension-intro" />
+
     <!-- 登录后:五维能量条 -->
     <FamilyEnergyBar
       v-if="isLoggedIn"
@@ -250,13 +266,14 @@ import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
+import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import DimensionSubDims from '../../components/DimensionSubDims.vue'
 import { getVisibleSections, getEnergyOverview, getChildren, getFamilyEnergySandbox, getEnergySandbox, getFamilyMemberList, getOctopusTentacles, getPearlResources, deletePearlResource, getAbilityMap, getRadarData, getPearlReminders, getOctopusEffects, getOctopusAnalysisSummary, getOctopusExpansionSuggest, getOctopusEffectKeyPersons } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, FloatingAvatar, RadarChart, DimensionSubDims },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, FamilyRelationGraph, PearlDiagram, AbilityDiagram, OctopusDiagram, FamilyMemberStrip, DimensionTasks, DimensionActivities, DimensionProducts, DimensionIntroCard, FloatingAvatar, RadarChart, DimensionSubDims, DimensionVisitorCard },
   data() {
     return {
       isLoggedIn: false,
@@ -658,13 +675,17 @@ export default {
     sectionVisible: function(key) {
       return this.visibleSections.length === 0 || this.visibleSections.indexOf(key) !== -1
     },
+    // 登录浮层成功回调:刷新行动维度数据
+    onLoginSuccess: function() {
+      this.loadDashboardData()
+    },
     onFuncClick: function(item) {
       if (item.needLogin && !this.isLoggedIn) {
         uni.showModal({
           title: '提示',
           content: '请先登录后使用此功能',
-          success: function(res) {
-            if (res.confirm) uni.navigateTo({ url: '/pages/login/login' })
+          success: (res) => {
+            if (res.confirm) this.openLoginSheet()
           }
         })
         return
@@ -913,7 +934,7 @@ export default {
   flex-direction: row;
   align-items: center;
   gap: 8rpx;
-  border-left: 6rpx solid #10B981;
+  border-left: 8rpx solid #10B981;
   padding-left: 16rpx;
 }
 .help-icon {
@@ -1246,7 +1267,7 @@ export default {
   padding: 16rpx 20rpx;
   background: linear-gradient(135deg, #F0FFF4, #E6FFF2);
   border-radius: 12rpx;
-  border-left: 4rpx solid #10B981;
+  border-left: 8rpx solid #10B981;
   margin-top: 16rpx;
 }
 .portrait-tip-text {

+ 13 - 4
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- 加载状态 -->
     <view v-if="loading" class="loading-wrap">
       <view class="loading-spinner"></view>
@@ -436,7 +438,7 @@ export default {
 
       // 未登录则跳转登录
       if (!uni.getStorageSync('token')) {
-        uni.navigateTo({ url: '/pages/login/login' })
+        self.openLoginSheet()
         return
       }
 
@@ -487,7 +489,7 @@ export default {
       var self = this
       // 未登录则跳转登录
       if (!uni.getStorageSync('token')) {
-        uni.navigateTo({ url: '/pages/login/login' })
+        self.openLoginSheet()
         return
       }
       // Open registration form modal
@@ -499,7 +501,7 @@ export default {
       var self = this
       // 未登录则跳转登录
       if (!uni.getStorageSync('token')) {
-        uni.navigateTo({ url: '/pages/login/login' })
+        self.openLoginSheet()
         return
       }
       uni.showModal({
@@ -672,12 +674,19 @@ export default {
       this.registrants = []
       this.familyMembers = []
     },
+    // 登录浮层成功回调:刷新活动详情与签到状态
+    onLoginSuccess: function() {
+      if (this.activity && this.activity.id) {
+        this.loadDetail(this.activity.id)
+      }
+      if (this.loadFeedbackStatus) this.loadFeedbackStatus()
+    },
     // ===== 分享海报 =====
     async generatePoster() {
       if (!this.activity || !this.activity.id) return
       // 未登录则跳转登录
       if (!uni.getStorageSync('token')) {
-        uni.navigateTo({ url: '/pages/login/login' })
+        this.openLoginSheet()
         return
       }
       uni.showLoading({ title: '生成海报中...' })

+ 3 - 1
cfc-frontend/pages/article-center/article-detail.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="detail-container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <view v-if="loading" class="loading-wrap">
       <view class="loading-spinner"></view>
       <text class="loading-text">加载中...</text>
@@ -271,7 +273,7 @@ export default {
     async startQuiz() {
       if (!this.isLogin) {
         uni.showToast({ title: '登录后即可完成知识测评', icon: 'none' })
-        setTimeout(function() { uni.navigateTo({ url: '/pages/login/login' }) }, 1500)
+        setTimeout(() => { this.openLoginSheet() }, 1500)
         return
       }
       var memberId = this.getActiveMemberId()

+ 7 - 1
cfc-frontend/pages/article-center/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="ac-container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- 自定义导航栏 -->
     <view class="ac-nav">
       <view class="ac-nav-left" @click="onBack">
@@ -259,7 +261,7 @@ onLoad(options) {
       if (isLoggedIn) {
         uni.navigateTo({ url: '/pages/article-center/article-edit' })
       } else {
-        uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/article-center/index') })
+        this.openLoginSheet({ redirect: '/pages/article-center/index' })
       }
     },
     goExplore() {
@@ -269,6 +271,10 @@ onLoad(options) {
       this.articles = []
       this.loadArticles()
     },
+    // 登录浮层成功回调:刷新文章列表(登录后可见编辑入口等)
+    onLoginSuccess() {
+      this.loadArticles()
+    },
     formatDate(dateStr) {
       if (!dateStr) return ''
       return dateStr.slice(0, 10)

+ 40 - 83
cfc-frontend/pages/body-detail/index.vue

@@ -1,11 +1,18 @@
 <template>
   <view class="body-container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- Tabbar 切换过渡页 -->
     <tab-transition v-if="showTabTransition" dimCode="body" ref="tabTransition" @close="showTabTransition = false" />
 
     <!-- 品牌头部 -->
     <PageBanner theme="body" tagline="科学管理全家健康" quote="身体健康是幸福的基础" />
 
+    <!-- 品牌定位语(登录前显示) -->
+    <view class="brand-strap" v-if="!isLoggedIn">
+      <text class="brand-strap-text">科学管理全家健康</text>
+    </view>
+
     <!-- 身维度画像 - 总体说明 -->
     <view class="dimension-portrait-section" v-if="isLoggedIn && bodyPortrait">
       <view class="section-header">
@@ -205,18 +212,19 @@
       </view>
     </view>
 
-    <!-- 游客主区域 -->
-    <view v-else class="visitor-body">
-      <view class="visitor-intro">
-        <text class="visitor-icon">🌿</text>
-        <text class="visitor-title">欢迎来到身维度</text>
-        <text class="visitor-desc">登录后查看身高体重、血压、心率等身体指标及趋势报告</text>
-      </view>
-      <view class="visitor-actions">
-        <button class="visitor-btn-primary" @click="goLogin">立即登录</button>
-        <button class="visitor-btn-secondary" @click="goReportList">查看报告</button>
-      </view>
-    </view>
+    <!-- 游客价值卡(统一组件:维度色 CTA + 价值预览) -->
+    <DimensionVisitorCard
+      v-else
+      dimCode="body"
+      card-title="身维度 · 全家健康管理"
+      :benefits="[
+        '身高体重 / 血压 / 心率趋势一目了然',
+        '肠道菌群健康与疾病风险预警',
+        '家庭成员健康画像对比'
+      ]"
+      cta-text="立即登录查看健康概览"
+      secondary-text="查看健康报告"
+      secondary-url="/pages/health/report-list" />
 
     <view class="bottom-spacer"></view>
     <FloatingAvatar />
@@ -230,6 +238,7 @@ import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
+import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 
 import {
@@ -247,7 +256,7 @@ import {
 } from '../../utils/api.js'
 
 export default {
-  components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard },
+  components: { TabTransition, PageBanner, FamilyEnergyBar, RadarChart, FloatingAvatar, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard },
   data() {
     return {
       isLoggedIn: false,
@@ -545,7 +554,7 @@ export default {
       uni.navigateTo({ url: '/pages/health/report-upload' })
     },
     goToLogin: function() {
-      uni.navigateTo({ url: '/pages/login/login' })
+      this.openLoginSheet()
     },
     goDimensionDetail: function(dim) {
       var url = '/pages/body-detail/dimension-detail?dim=' + dim.key
@@ -556,8 +565,8 @@ export default {
         uni.showModal({
           title: '提示',
           content: '请先登录后使用此功能',
-          success: function(res) {
-            if (res.confirm) uni.navigateTo({ url: '/pages/login/login' })
+          success: (res) => {
+            if (res.confirm) this.openLoginSheet()
           }
         })
         return
@@ -601,7 +610,11 @@ export default {
       return n.toFixed(1)
     },
     goLogin: function() {
-      uni.navigateTo({ url: '/pages/login/login' })
+      this.openLoginSheet()
+    },
+    // 登录浮层成功回调:刷新身维度数据
+    onLoginSuccess: function() {
+      this.loadDashboardData()
     },
     goReportList: function() {
       uni.navigateTo({ url: '/pages/health/report-list' })
@@ -785,6 +798,7 @@ export default {
   align-items: center;
   padding: 24rpx 24rpx 16rpx;
   border-bottom: 1rpx solid #F5F5F5;
+  border-left: 8rpx solid #FF8C42;
 }
 
 .section-title {
@@ -1093,7 +1107,7 @@ export default {
   padding: 16rpx 20rpx;
   background: linear-gradient(135deg, #FFF7ED, #FFF0E4);
   border-radius: 12rpx;
-  border-left: 4rpx solid #FF8C42;
+  border-left: 8rpx solid #FF8C42;
 }
 .portrait-tip-text {
   font-size: 24rpx;
@@ -1120,74 +1134,17 @@ export default {
   color: #FF8C42;
 }
 
-/* ─── 游客区域 ─── */
-.visitor-body {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  padding: 60rpx 32rpx;
-}
-
-.visitor-intro {
-  text-align: center;
-  margin-bottom: 48rpx;
-}
-
-.visitor-icon {
-  font-size: 80rpx;
-  margin-bottom: 20rpx;
-  display: block;
-}
-
-.visitor-title {
-  font-size: 36rpx;
-  font-weight: 700;
-  color: #FF8C42;
-  margin-bottom: 12rpx;
-  display: block;
-}
-
-.visitor-desc {
-  font-size: 24rpx;
-  color: #888;
-  line-height: 1.6;
-}
-
-.visitor-actions {
-  display: flex;
-  gap: 20rpx;
-  width: 100%;
-  max-width: 560rpx;
-}
-
-.visitor-btn-primary {
-  flex: 1;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: linear-gradient(135deg, #FF8C42, #FF6B1A);
-  border-radius: 48rpx;
-  padding: 24rpx 0;
-  color: #FFFFFF;
-  font-size: 28rpx;
-  font-weight: 600;
-  box-shadow: 0 4rpx 16rpx rgba(255, 107, 26, 0.35);
+/* ─── 品牌定位语(登录前) ─── */
+.brand-strap {
+  margin: 0 24rpx 16rpx;
+  background: linear-gradient(135deg, #FFF1E6, rgba(255, 140, 66, 0.10));
+  border-radius: 16rpx;
+  padding: 20rpx 24rpx;
 }
-
-.visitor-btn-secondary {
-  flex: 1;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background-color: #FFFFFF;
-  border-radius: 48rpx;
-  padding: 24rpx 0;
+.brand-strap-text {
+  font-size: 26rpx;
   color: #FF8C42;
-  font-size: 28rpx;
   font-weight: 600;
-  border: 2rpx solid #FF8C42;
 }
 
 .bottom-spacer {

+ 195 - 0
cfc-frontend/pages/dan-assessment/comparison.vue

@@ -0,0 +1,195 @@
+<template>
+  <view class="page">
+    <view class="filter-bar">
+      <view class="filter-item">{{ currentMemberName }} ▾</view>
+      <picker :range="reportIds" @change="onReportIdChange">
+        <view class="filter-item">{{ currentReportIdLabel }}</view>
+      </picker>
+      <picker :range="[ 'uploadIdA', 'uploadIdB' ]" @change="onUploadIdChange">
+        <view class="filter-item">{{ currentUploadLabel }}</view>
+      </picker>
+    </view>
+
+    <view v-if="loading" class="loading-wrap">
+      <text class="loading-text">加载中...</text>
+    </view>
+
+    <view v-if="error" class="error-wrap">
+      <text class="error-text">{{ error }}</text>
+    </view>
+
+    <view v-if="comparison" class="comparison-wrap">
+      <view class="section-title">DAN 报告对比</view>
+
+      <view class="comparison-info">
+        <view class="info-item">
+          <text class="label">首次上传</text>
+          <text class="value">{{ firstUploadLabel }}</text>
+        </view>
+        <view class="info-item">
+          <text class="label">最近上传</text>
+          <text class="value">{{ secondUploadLabel }}</text>
+        </view>
+      </view>
+
+      <view v-if="deltas && deltas.length > 0" class="deltas-list">
+        <view v-for="(delta, di) in deltas" :key="di" class="delta-item">
+          <view :class="delta.status === 'added' ? 'delta-added' : delta.status === 'removed' ? 'delta-removed' : 'delta-modified'">
+            <text class="delta-code">{{ delta.code || '—' }}</text>
+            <view class="delta-detail">
+              <text v-if="delta.status === 'added'">新增</text>
+              <text v-if="delta.status === 'removed'">删除</text>
+              <text v-if="delta.status === 'modified'">修改</text>
+              <text class="delta-name">{{ delta.name1 || '' }} → {{ delta.name2 || '' }}</text>
+              <text v-if="delta.change">({{ delta.change }})</text>
+              <text v-if="delta.valueDelta !== null">Δ:{{ delta.valueDelta }}</text>
+              <text v-if="delta.categoryChanged">类别变化</text>
+            </view>
+          </view>
+        </view>
+      </view>
+      <view v-else class="empty-tip">两次上传无变化差异</view>
+    </view>
+    <view v-else class="empty-tip">暂无对比数据</view>
+  </view>
+</template>
+
+<script>
+import { getFamilyMemberList, getDanReportList, compareDanReports } from '@/utils/api'
+
+export default {
+  components: {},
+  data() {
+    return {
+      memberId: null,
+      reportIds: [],
+      currentReportId: null,
+      currentMemberName: '',
+      currentReportIdLabel: '选择报告',
+      currentUploadLabel: '选择上传',
+      uploadIdA: null,
+      uploadIdB: null,
+      loading: true,
+      error: null,
+      comparison: null
+    }
+  },
+  computed: {
+    memberNames() {
+      return this.memberId ? this.members.map(m => m.name || m.nickname || '').filter(Boolean) : []
+    }
+  },
+  onLoad(options) {
+    var self = this
+    // 从 query 获取 memberId,或默认取当前登录成员
+    if (options.memberId) {
+      self.memberId = parseInt(options.memberId)
+    }
+    self.loadMembers()
+  },
+  methods: {
+    loadMembers() {
+      var self = this
+      getFamilyMemberList({ visibleOnly: true }).then(function(res) {
+        var arr = (res.data && res.data.list) || (res.data instanceof Array ? res.data : [])
+        self.members = arr
+        if (!self.memberId && arr.length > 0) {
+          self.memberId = arr[0].id
+        }
+        self.loadReportIds()
+      })
+    },
+    loadReportIds() {
+      var self = this
+      if (!self.memberId) {
+        self.reportIds = []
+        self.currentReportIdLabel = '选择成员后加载'
+        return
+      }
+      getDanReportList({ memberId: self.memberId }).then(function(res) {
+        self.reportIds = (res.data || []).map(function(u) { return String(u.id) })
+        if (self.reportIds.length > 0) {
+          self.currentReportId = self.reportIds[0]
+          self.onReportIdChange(self.reportIds[0])
+        }
+      }).catch(function(e) {
+        self.error = '获取报告列表失败'
+        console.error(e)
+      })
+    },
+    onReportIdChange(value) {
+      this.currentReportId = value
+      this.loadComparison()
+    },
+    onUploadIdChange(value, uploadType) {
+      if (uploadType === 'A') {
+        this.uploadIdA = value
+      } else {
+        this.uploadIdB = value
+      }
+      this.loadComparison()
+    },
+    loadComparison() {
+      var self = this
+      if (!self.uploadIdA || !self.uploadIdB) {
+        self.comparison = null
+        return
+      }
+      self.loading = true
+      compareDanReports({ uploadIdA: self.uploadIdA, uploadIdB: self.uploadIdB }).then(function(res) {
+        self.loading = false
+        if (res.code !== 200) {
+          self.error = res.message || '对比失败'
+          return
+        }
+        self.comparison = res.data
+        self.error = null
+      }).catch(function(e) {
+        self.loading = false
+        self.error = '对比异常'
+        console.error(e)
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #F5F9FC;
+  padding: 24rpx;
+  box-sizing: border-box;
+}
+.filter-bar {
+  display: flex;
+  flex-direction: row;
+  margin-bottom: 24rpx;
+}
+.filter-item {
+  flex: 1;
+  background: #FFFFFF;
+  border: 1rpx solid #E2E8F0;
+  border-radius: 16rpx;
+  padding: 16rpx 24rpx;
+  margin-right: 16rpx;
+  font-size: 26rpx;
+  color: #1E293B;
+  text-align: center;
+}
+.filter-item:last-child { margin-right: 0; }
+
+/* 对比区 */
+.comparison-wrap { background: #FFFFFF; border-radius: 20rpx; padding: 24rpx; margin-bottom: 24rpx; box-shadow: 0 4rpx 16rpx rgba(30,41,59,0.06); }
+.section-title { font-size: 30rpx; font-weight: 600; color: #1E293B; margin-bottom: 20rpx; }
+.info-item { display: flex; align-items: center; margin-bottom: 16rpx; }
+.label { font-size: 24rpx; color: #64748B; margin-right: 12rpx; }
+.value { font-size: 24rpx; color: #1E293B; }
+.deltas-list { margin-top: 24rpx; }
+.delta-item { padding: 12rpx 0; border-bottom: 1rpx solid #E2E8F0; display: flex; align-items: center; }
+.delta-code { flex: 1; font-size: 24rpx; color: #64748B; }
+.delta-added { color: #32CD32; }
+.delta-removed { color: #FF6B6B; }
+.delta-modified { color: #FFA500; }
+.empty-tip { padding: 60rpx 0; text-align: center; font-size: 26rpx; color: #94A3B8; }
+</style>

+ 11 - 3
cfc-frontend/pages/diet/food-query.vue

@@ -31,8 +31,7 @@
       </view>
     </view>
 
-    <scroll-view class="content" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
-      <!-- 搜索栏 -->
+    <!-- 搜索栏(固定顶部,不随列表滚动) -->
       <view class="search-bar">
         <input class="search-input" placeholder="搜索食材..." v-model="searchQuery" @confirm="searchFood" />
         <view class="search-btn" @tap="searchFood">搜索</view>
@@ -76,6 +75,8 @@
         </view>
       </view>
 
+      <!-- 列表区(仅此区域滚动) -->
+      <scroll-view class="content" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
       <!-- 家庭总览 -->
       <view class="family-overview" v-if="currentView === 'family' && familyOverview && familyOverview.length > 0">
         <view class="overview-title">
@@ -384,8 +385,11 @@ export default {
     },
     setFilter: function(type) {
       this.filterType = type
+      // 适合/全部:倒序,推荐值大的排前面;戒备:升序,推荐值小的排前面
       if (type === 'unsuitable') {
         this.sortType = 'asc'
+      } else {
+        this.sortType = 'desc'
       }
     },
     setSort: function(type) {
@@ -413,7 +417,8 @@ export default {
 
 <style scoped>
 .food-query-page {
-  min-height: 100vh;
+  height: 100vh;
+  overflow: hidden;
   background-color: #F5F7FA;
   display: flex;
   flex-direction: column;
@@ -490,7 +495,10 @@ export default {
 
 .content {
   flex: 1;
+  min-height: 0;
+  overflow: hidden;
   padding: 20rpx 30rpx;
+  box-sizing: border-box;
 }
 
 /* 分类标签 */

+ 17 - 5
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 通用登录浮层 -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <view v-if="loading" class="loading-wrap">
       <text class="loading-text">加载中...</text>
     </view>
@@ -536,10 +538,20 @@ export default {
       this.$set(item, '_selected', !item._selected)
     },
 
+    // 登录成功:刷新商品详情(会员价等以登录态为准)
+    onLoginSuccess: function() {
+      if (this.product && this.product.id) {
+        this.loadDetail(this.product.id)
+      }
+      if (!this.referralCode) {
+        this.loadReferralCode()
+      }
+    },
+
     onBuy: function() {
       var userId = uni.getStorageSync('userId')
       if (!userId) {
-        uni.navigateTo({ url: '/pages/login/login' })
+        this.openLoginSheet()
         return
       }
       var price = this.actualPrice
@@ -587,7 +599,7 @@ export default {
     onAddToCart: function() {
       var userId = uni.getStorageSync('userId')
       if (!userId) {
-        uni.navigateTo({ url: '/pages/login/login' })
+        this.openLoginSheet()
         return
       }
       uni.showLoading({ title: '加入购物车...' })
@@ -885,7 +897,7 @@ export default {
   display: flex;
   align-items: center;
   padding: 16rpx 24rpx;
-  background: linear-gradient(135deg, #667eea22, #764ba222);
+  background: linear-gradient(135deg, #F9731622, #FB923C22);
   border-radius: 16rpx;
   margin-top: 12rpx;
 }
@@ -895,7 +907,7 @@ export default {
 }
 .cf-hint-text {
   font-size: 24rpx;
-  color: #667eea;
+  color: #F97316;
   font-weight: 500;
 }
 
@@ -1129,7 +1141,7 @@ export default {
   font-size: 40rpx;
   margin-right: 16rpx;
   flex-shrink: 0;
-  background: linear-gradient(135deg, #667eea22, #764ba222);
+  background: linear-gradient(135deg, #F9731622, #FB923C22);
   border-radius: 12rpx;
 }
 .gift-select-info {

+ 8 - 1
cfc-frontend/pages/discover/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="discover-container">
+    <!-- 通用登录浮层 -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
 <!-- 五行沙盘预览 -->
     <wuxing-sandbox mode="preview" badgeText="登录查看完整报告" @point-click="handleLogin" />
 
@@ -205,7 +207,12 @@ export default {
       uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + id })
     },
     handleLogin() {
-      uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/discover/index') })
+      this.openLoginSheet({ redirect: '/pages/discover/index' })
+    },
+    // 登录成功:刷新推荐列表
+    onLoginSuccess() {
+      this.loadProducts()
+      this.loadFeaturedArticles()
     },
     async loadFeaturedArticles() {
       try {

+ 17 - 10
cfc-frontend/pages/energy/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- 顶部导航栏 -->
     <view class="nav-bar" :style="{ background: navGradient }">
       <view class="nav-back" @click="goBack">
@@ -74,7 +76,7 @@
           :avg-scores="radarFamilyScores"
           :population-scores="[]"
           fillColor="rgba(102,126,234,0.2)"
-          strokeColor="#667eea"
+          strokeColor="#F97316"
           gridColor="rgba(102,126,234,0.15)"
           labelColor="#888"
           :width="580"
@@ -143,7 +145,7 @@ import { getFamilyEnergySandbox, getFamilyMemberList } from '../../utils/api.js'
 
 var DIM_CONFIG = [
   { code: 'body',    label: '身', icon: '💪', color: '#10B981', gradient: 'linear-gradient(135deg,#10B981,#34D399)' },
-  { code: 'mind',    label: '心', icon: '❤️', color: '#8B5CF6', gradient: 'linear-gradient(135deg,#8B5CF6,#A78BFA)' },
+  { code: 'mind',    label: '心', icon: '❤️', color: '#F97316', gradient: 'linear-gradient(135deg,#F97316,#FB923C)' },
   { code: 'wisdom',  label: '智', icon: '🧠', color: '#F59E0B', gradient: 'linear-gradient(135deg,#F59E0B,#FBBF24)' },
   { code: 'action',  label: '行', icon: '⚡', color: '#F97316', gradient: 'linear-gradient(135deg,#F97316,#FB923C)' },
   { code: 'wealth',  label: '富', icon: '💎', color: '#3B82F6', gradient: 'linear-gradient(135deg,#3B82F6,#60A5FA)' }
@@ -200,9 +202,9 @@ export default {
       return '👤'
     },
     avatarGradient() {
-      if (this.currentView === 'family') return 'linear-gradient(135deg,#667eea,#764ba2)'
+      if (this.currentView === 'family') return 'linear-gradient(135deg,#F97316,#FB923C)'
       if (this.currentMember) return this.memberColor(this.currentMember)
-      return 'linear-gradient(135deg,#8B5CF6,#A78BFA)'
+      return 'linear-gradient(135deg,#F97316,#FB923C)'
     },
     navTitle() { return this.currentMemberName + ' · 五维能量' },
     currentMemberIcon() {
@@ -216,12 +218,12 @@ export default {
       return this.currentMember.name || '我的'
     },
     navGradient() {
-      if (this.currentView === 'family') return 'linear-gradient(135deg,#667eea,#764ba2)'
+      if (this.currentView === 'family') return 'linear-gradient(135deg,#F97316,#FB923C)'
       if (this.currentMember) {
         var dim = DIM_CONFIG[0]
         return dim.gradient
       }
-      return 'linear-gradient(135deg,#667eea,#764ba2)'
+      return 'linear-gradient(135deg,#F97316,#FB923C)'
     },
     pageTitle() { return this.navTitle },
     radarDims() {
@@ -264,13 +266,18 @@ export default {
       this.isLoggedIn = !!uni.getStorageSync('token')
     },
     goLogin() {
-      uni.navigateTo({ url: '/pages/login/login' })
+      this.openLoginSheet()
+    },
+    // 登录浮层成功回调:刷新登录态与能量数据
+    onLoginSuccess() {
+      this.checkLogin()
+      if (this.isLoggedIn) this.loadData()
     },
     goBack() {
       uni.navigateBack({ fail: function() { uni.switchTab({ url: '/pages/index-home/index' }) } })
     },
     memberColor(member) {
-      if (!member) return 'linear-gradient(135deg,#8B5CF6,#A78BFA)'
+      if (!member) return 'linear-gradient(135deg,#F97316,#FB923C)'
       var types = { parent: '#F59E0B', child: '#10B981', teacher: '#3B82F6' }
       var t = member.memberType || 'parent'
       var colors = {
@@ -492,7 +499,7 @@ export default {
   width: 280rpx;
   height: 80rpx;
   line-height: 80rpx;
-  background: linear-gradient(135deg, #667eea, #764ba2);
+  background: linear-gradient(135deg, #F97316, #FB923C);
   color: #fff;
   border-radius: 40rpx;
   font-size: 28rpx;
@@ -526,7 +533,7 @@ export default {
 .overview-name { font-size: 32rpx; font-weight: 600; color: #fff; display: block; }
 .overview-role { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; }
 .overview-score { text-align: right; }
-.score-value { font-size: 48rpx; font-weight: 700; color: #667eea; display: block; line-height: 1; }
+.score-value { font-size: 48rpx; font-weight: 700; color: #F97316; display: block; line-height: 1; }
 .score-label { font-size: 20rpx; color: rgba(255,255,255,0.4); }
 /* ===== 五维能量条 ===== */
 .dims-row {

+ 2 - 2
cfc-frontend/pages/family/add-member.vue

@@ -108,7 +108,7 @@
 
 <script>
 import { addFamilyMember, updateFamilyMember, recordConsent, checkPhone } from '../../utils/api.js'
-import GenerationPicker from '@/pages/family/components/GenerationPicker.vue'
+import GenerationPicker from '@/components/GenerationPicker.vue'
 import { parseDate } from '../../utils/format.js'
 
 export default {
@@ -392,7 +392,7 @@ this.submitting = false
 }
 .modal-link {
   font-size: 24rpx;
-  color: #667eea;
+  color: #F97316;
   display: block;
   text-align: center;
   margin-bottom: 24rpx;

+ 10 - 0
cfc-frontend/pages/growth-main/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 通用登录浮层 -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <view v-if="!isLoggedIn">
       <!-- 品牌定位语 -->
       <view class="brand-strap"><text class="brand-strap-text">看见改变正在发生</text></view>
@@ -325,6 +327,14 @@ export default {
     goMoreProducts: function() { uni.navigateTo({ url: '/pages/shop/index/index' }) },
     goMoreArticles: function() { uni.navigateTo({ url: '/pages/article-center/index?dimension=action' }) },
     goPage: function(url) { uni.navigateTo({ url: url }) },
+    // 登录成功:刷新登录态数据(镜像 onShow 已登录分支)
+    onLoginSuccess: function() {
+      this.isLoggedIn = true
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || ''
+      var now = new Date()
+      this.todayDate = now.getFullYear() + '-' + this.pad(now.getMonth() + 1) + '-' + this.pad(now.getDate())
+      this.loadAllData()
+    },
     formatDateKey: function(dateVal) {
       if (!dateVal) return ''
       var d = parseDate(dateVal)

+ 16 - 1
cfc-frontend/pages/health-main/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 通用登录浮层 -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <block v-if="!isLoggedIn">
       <!-- 品牌定位语 -->
       <view class="brand-strap"><text class="brand-strap-text">科学管理全家健康数据</text></view>
@@ -471,6 +473,19 @@ export default {
     },
     switchTab: function(code) { this.currentTab = code },
     goPage: function(url) { uni.navigateTo({ url: url }) },
+    // 登录成功:刷新登录态数据(镜像 onShow 已登录分支)
+    onLoginSuccess: function() {
+      this.isLoggedIn = true
+      var stored = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || ''
+      if (stored) {
+        var n = parseInt(stored, 10)
+        this.selectedMemberId = isNaN(n) ? stored : n
+        this.selectedMemberName = uni.getStorageSync('currentChildName') || '成员'
+      }
+      this.loadHealthData()
+      this.loadPlanList()
+      this.loadPendingSurveys()
+    },
     goQuiz: function(code) {
       var url = '/pages/body-detail/dimension-questionnaire?dim=' + code
       if (this.selectedMemberId) url += '&memberId=' + this.selectedMemberId
@@ -721,7 +736,7 @@ export default {
 .feat-icon { font-size: 44rpx; display: block; margin-bottom: 10rpx; }
 .feat-title { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 4rpx; }
 .feat-desc { font-size: 22rpx; color: #999; display: block; }
-.self-test-card { margin: 0 24rpx 16rpx; background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 20rpx; padding: 24rpx 28rpx; }
+.self-test-card { margin: 0 24rpx 16rpx; background: linear-gradient(135deg, #F97316, #FB923C); border-radius: 20rpx; padding: 24rpx 28rpx; }
 .self-test-head { display: flex; align-items: center; }
 .self-test-icon { font-size: 40rpx; margin-right: 16rpx; }
 .self-test-info { flex: 1; }

+ 302 - 0
cfc-frontend/pages/health/components/TrendChart.vue

@@ -0,0 +1,302 @@
+<template>
+  <view class="trend-chart-wrap">
+    <view class="trend-container" :style="containerStyle">
+      <canvas :id="canvasId" type="2d" class="trend-canvas" :style="canvasStyle"></canvas>
+      <view class="trend-tap-layer" @click="onCanvasTap"></view>
+    </view>
+    <view class="trend-legend" v-if="series.length > 1">
+      <view class="legend-item" v-for="(s, i) in series" :key="getItemKey(s, i)">
+        <view class="legend-line" :style="'border-color:' + s.color"></view>
+        <text class="legend-text">{{ s.name }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+/**
+ * TrendChart - 多序列折线图 Canvas 组件
+ * 用法:<TrendChart :series="[{name,color,points:[{reportDate,value,unit}]}]" height="360" />
+ * 参照 RadarChart.vue 的 type=2d + DPR + 重试初始化模式。
+ */
+export default {
+  props: {
+    /** 序列数组 [{ name, color, points:[{reportDate,value,unit}] }] */
+    series: { type: Array, default: function() { return [] } },
+    width: { type: Number, default: 690 },
+    height: { type: Number, default: 420 },
+    /** Y 轴自动范围下界(默认 min-5%) */
+    yMin: { type: Number, default: 0 },
+    /** 是否绘制数据点数值标注 */
+    showValues: { type: Boolean, default: true }
+  },
+  data: function() {
+    return {
+      canvasReady: false, canvasWidth: 0, canvasHeight: 0, ctx: null, dpr: 1,
+      _canvasNode: null, _canvasLeft: 0, _canvasTop: 0,
+      _destroyed: false, _initTimer: null,
+      canvasId: 'trendCanvas_' + (this._uid || 0)
+    }
+  },
+  computed: {
+    containerStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' },
+    canvasStyle: function() { return 'width:' + this.width + 'rpx;height:' + this.height + 'rpx' }
+  },
+  watch: {
+    series: { handler: function() { if (this.canvasReady) this._doRender() }, deep: true }
+  },
+  mounted: function() {
+    var self = this
+    var retryCount = 0
+    var maxRetry = 5
+    function tryInit() {
+      if (self._destroyed) return
+      self.initCanvas(function(success) {
+        if (self._destroyed) return
+        if (!success && retryCount < maxRetry) {
+          retryCount++
+          self._initTimer = setTimeout(tryInit, 60)
+        } else if (!success) {
+          console.log('[TrendChart] 初始化失败,重试 ' + maxRetry + ' 次后放弃', self.canvasId)
+        }
+      })
+    }
+    self._initTimer = setTimeout(tryInit, 100)
+  },
+  beforeDestroy: function() {
+    this._destroyed = true
+    if (this._initTimer) { clearTimeout(this._initTimer); this._initTimer = null }
+  },
+  methods: {
+    getItemKey: function(s, i) { return (s && s.name) || i },
+
+    initCanvas: function(cb) {
+      var self = this
+      uni.createSelectorQuery().in(this)
+        .select('#' + this.canvasId)
+        .fields({ node: true, size: true, rect: true })
+        .exec(function(res) {
+          try {
+            if (self._destroyed) return
+            if (!res || !res[0] || !res[0].node) { if (cb) cb(false); return }
+            var info = res[0]
+            var canvas = info.node
+            var width = info.width
+            var height = info.height
+            if (!width || !height) {
+              var winWidth = 375
+              try { winWidth = uni.getWindowInfo().windowWidth || 375 } catch (e) {}
+              width = Math.round((self.width || 690) * winWidth / 750)
+              height = Math.round((self.height || 420) * winWidth / 750)
+            }
+            var pixelRatio = 2
+            try { pixelRatio = uni.getWindowInfo().pixelRatio || 2 } catch (e) {}
+            canvas.width = width * pixelRatio
+            canvas.height = height * pixelRatio
+            var ctx = canvas.getContext('2d')
+            if (!ctx) { if (cb) cb(false); return }
+            ctx.scale(pixelRatio, pixelRatio)
+            self.ctx = ctx
+            self._canvasNode = canvas
+            self.canvasWidth = width
+            self.canvasHeight = height
+            self.dpr = pixelRatio
+            self._canvasLeft = info.left || 0
+            self._canvasTop = info.top || 0
+            self.canvasReady = true
+            self._doRender()
+            if (cb) cb(true)
+          } catch (e) {
+            console.log('[TrendChart] initCanvas 回调异常', e)
+            if (cb) cb(false)
+          }
+        })
+    },
+
+    _safeSeries: function(arr) {
+      if (!Array.isArray(arr)) return []
+      return arr.filter(function(s) { return s && Array.isArray(s.points) && s.points.length > 0 })
+    },
+
+    /** 计算 X 轴时间范围与 Y 轴数值范围 */
+    _computeRanges: function(seriesList) {
+      var min = Infinity, max = -Infinity, firstDate = null, lastDate = null
+      seriesList.forEach(function(s) {
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (!isNaN(v)) { if (v < min) min = v; if (v > max) max = v }
+          if (!firstDate || p.reportDate < firstDate) firstDate = p.reportDate
+          if (!lastDate || p.reportDate > lastDate) lastDate = p.reportDate
+        })
+      })
+      if (min === Infinity) { min = 0; max = 1 }
+      if (max === min) { max = min + 1 }
+      var pad = (max - min) * 0.08
+      return { yMin: Math.max(0, min - pad), yMax: max + pad, firstDate: firstDate, lastDate: lastDate }
+    },
+
+    _doRender: function() {
+      if (!this.ctx) return
+      var self = this
+      var ctx = this.ctx
+      var w = this.canvasWidth, h = this.canvasHeight
+      var padL = 34, padR = 14, padT = 16, padB = 26
+      var plotW = w - padL - padR, plotH = h - padT - padB
+      var seriesList = this._safeSeries(this.series)
+      if (seriesList.length === 0) {
+        ctx.clearRect(0, 0, w, h)
+        ctx.fillStyle = '#94A3B8'
+        ctx.font = '12px sans-serif'
+        ctx.textAlign = 'center'
+        ctx.fillText('暂无趋势数据', w / 2, h / 2)
+        return
+      }
+      var ranges = this._computeRanges(seriesList)
+      var yMin = ranges.yMin, yMax = ranges.yMax
+      function yOf(v) { return padT + plotH * (1 - (v - yMin) / (yMax - yMin)) }
+      // 日期横轴:按所有序列全部点排序后均分
+      var allDates = []
+      seriesList.forEach(function(s) {
+        s.points.forEach(function(p) {
+          if (allDates.indexOf(p.reportDate) < 0) allDates.push(p.reportDate)
+        })
+      })
+      allDates.sort()
+      function xOf(dateStr) {
+        if (allDates.length <= 1) return padL + plotW / 2
+        var idx = allDates.indexOf(dateStr)
+        if (idx < 0) return padL
+        return padL + plotW * (allDates.length === 1 ? 0.5 : idx / (allDates.length - 1))
+      }
+
+      ctx.clearRect(0, 0, w, h)
+      // 网格(4 条横线 + 2 条纵线)
+      ctx.strokeStyle = '#EEF2F7'
+      ctx.lineWidth = 1
+      for (var gi = 0; gi <= 3; gi++) {
+        var gy = padT + plotH * (gi / 3)
+        ctx.beginPath()
+        ctx.moveTo(padL, gy); ctx.lineTo(w - padR, gy)
+        ctx.stroke()
+      }
+      // Y 轴刻度文字
+      ctx.fillStyle = '#94A3B8'
+      ctx.font = '10px sans-serif'
+      ctx.textAlign = 'right'
+      for (var ti = 0; ti <= 3; ti++) {
+        var tv = yMin + (yMax - yMin) * (1 - ti / 3)
+        var ty = padT + plotH * (ti / 3)
+        ctx.fillText(self._fmtNum(tv), padL - 4, ty + 3)
+      }
+      // 折线 + 数据点 + 数值
+      var palette = ['#F97316', '#0EA5E9', '#10B981', '#F97316', '#EF4444', '#F59E0B']
+      seriesList.forEach(function(s, si) {
+        var color = s.color || palette[si % palette.length]
+        ctx.strokeStyle = color
+        ctx.lineWidth = 2
+        ctx.beginPath()
+        var started = false
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (isNaN(v)) return
+          var x = xOf(p.reportDate), y = yOf(v)
+          if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) }
+        })
+        ctx.stroke()
+        // 数据点 + 值
+        s.points.forEach(function(p) {
+          var v = parseFloat(p.value)
+          if (isNaN(v)) return
+          var x = xOf(p.reportDate), y = yOf(v)
+          ctx.fillStyle = color
+          ctx.beginPath(); ctx.arc(x, y, 3.5, 0, 2 * Math.PI); ctx.fill()
+          if (self.showValues) {
+            ctx.fillStyle = '#475569'
+            ctx.font = '10px sans-serif'
+            ctx.textAlign = 'center'
+            ctx.fillText(self._fmtValue(p), x, y - 8)
+          }
+        })
+        // 序列名(最右端上方)
+        ctx.fillStyle = color
+        ctx.font = '11px sans-serif'
+        ctx.textAlign = 'right'
+        var last = s.points[s.points.length - 1]
+        if (last && !isNaN(parseFloat(last.value))) {
+          ctx.fillText(s.name || '', xOf(last.reportDate), yOf(parseFloat(last.value)) - 14)
+        }
+      })
+      // X 轴日期标签(首/中/末)
+      ctx.fillStyle = '#94A3B8'
+      ctx.font = '10px sans-serif'
+      ctx.textAlign = 'center'
+      if (allDates.length > 0) {
+        ctx.fillText(String(allDates[0]).slice(5), padL, h - 8)
+        if (allDates.length > 1) {
+          var midIdx = Math.floor((allDates.length - 1) / 2)
+          ctx.fillText(String(allDates[midIdx]).slice(5), padL + plotW * (midIdx / (allDates.length - 1)), h - 8)
+          ctx.fillText(String(allDates[allDates.length - 1]).slice(5), w - padR, h - 8)
+        }
+      }
+    },
+
+    _fmtNum: function(v) {
+      if (v >= 100) return Math.round(v) + ''
+      if (v >= 10) return v.toFixed(1)
+      return v.toFixed(2)
+    },
+    _fmtValue: function(p) {
+      var v = parseFloat(p.value)
+      if (isNaN(v)) return '--'
+      return this._fmtNum(v) + (p.unit ? p.unit : '')
+    },
+
+    onCanvasTap: function(e) {
+      // 一期无点击交互需求,预留:点击最近数据点可 emit 'pointTap'
+      this.$emit('chartTap')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.trend-chart-wrap {
+  display: flex;
+  flex-direction: column;
+}
+.trend-container {
+  position: relative;
+}
+.trend-canvas {
+  display: block;
+}
+.trend-tap-layer {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+}
+.trend-legend {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: center;
+  padding-top: 12rpx;
+}
+.legend-item {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin: 0 16rpx 8rpx;
+}
+.legend-line {
+  width: 32rpx;
+  border-top: 4rpx solid #0EA5E9;
+  margin-right: 8rpx;
+}
+.legend-text {
+  font-size: 22rpx;
+  color: #475569;
+}
+</style>

+ 47 - 10
cfc-frontend/pages/health/indicator-curve.vue

@@ -62,15 +62,17 @@
             <text :class="'rank-delta ' + item._cssClass">{{ item._deltaText }}</text>
           </view>
           <text :class="'rank-trend ' + item._cssClass">{{ item._trendLabel }} {{ item._arrow }}</text>
+          <text class="rank-gap" v-if="item.skipGaps > 0">中间 {{ item.skipGaps }} 期缺失</text>
         </view>
       </view>
+      <view class="rank-empty" v-else>暂无变化数据</view>
     </view>
   </view>
 </template>
 
 <script>
-import TrendChart from '@/components/TrendChart.vue'
-import { getIndicatorTrend, getIndicatorChangeRanking } from '@/utils/api'
+import TrendChart from '@/pages/health/components/TrendChart.vue'
+import { getIndicatorTrend, getIndicatorChangeRanking, getIndicatorSourceTypes } from '@/utils/api'
 import { getFamilyMemberList } from '@/utils/api'
 import { resolveChangeStyle, formatDelta, colorClassFor } from '@/utils/indicator-change'
 import { parseDate } from '@/utils/format'
@@ -80,11 +82,8 @@ export default {
   data: function() {
     return {
       members: [], currentMemberId: null,
-      reportTypeOptions: [
-        { value: 'gut_flora', label: '肠道菌群' },
-        { value: 'food_recommend', label: '食材推荐' }
-      ],
-      activeReportType: 'gut_flora',
+      reportTypeOptions: [],
+      activeReportType: '',
       indicatorOptions: [], theIndicatorId: null,
       series: [], loadedDefinitionIds: [],
       rankTabs: [ { value: 'flora', label: '菌群变化' }, { value: 'food', label: '食材变化' }, { value: 'indicator', label: '指标变化' } ],
@@ -108,7 +107,7 @@ export default {
     },
     currentReportTypeName: function() {
       var self = this
-      var o = this.reportTypeOptions.find(function(x) { return x.value === self.activeReportType })
+      var o = this.reportTypeOptions.find(function(x) { return x.sourceType === self.activeReportType })
       return (o && o.label) || ''
     }
   },
@@ -142,10 +141,34 @@ export default {
         if (!self.currentMemberId && arr.length > 0) {
           self.currentMemberId = arr[0].id
           self.onMemberSelected()
+          self.loadSourceTypes()
         }
       })
     },
     onMemberSelected: function() { this.reloadAll() },
+    loadSourceTypes: function() {
+      var self = this
+      if (!this.currentMemberId) return
+      // 报告类型动态化:以该成员 indicator_values 实际存在的 source_type 为准,避免硬编码类型错配
+      getIndicatorSourceTypes({ subjectId: this.currentMemberId }).then(function(res) {
+        var arr = (res.data instanceof Array) ? res.data : []
+        if (arr.length > 0) {
+          self.reportTypeOptions = arr
+          var first = arr[0].sourceType
+          if (self.activeReportType !== first) {
+            self.activeReportType = first
+            self.reloadAll()
+          }
+        } else {
+          // 接口空/失败回退历史默认,避免筛选器无选项
+          self.reportTypeOptions = [
+            { sourceType: 'gut_flora', label: '肠道菌群' },
+            { sourceType: 'food_recommend', label: '食材推荐' }
+          ]
+          if (!self.activeReportType) { self.activeReportType = 'gut_flora'; self.reloadAll() }
+        }
+      })
+    },
     onMemberChange: function(e) {
       var idx = Number(e.detail.value)
       var m = this.members[idx]
@@ -154,7 +177,7 @@ export default {
     onReportTypeChange: function(e) {
       var idx = Number(e.detail.value)
       var o = this.reportTypeOptions[idx]
-      if (o) { this.activeReportType = o.value; this.reloadAll() }
+      if (o) { this.activeReportType = o.sourceType; this.reloadAll() }
     },
     reloadAll: function() {
       if (!this.currentMemberId) return
@@ -168,7 +191,9 @@ export default {
     loadRankingThenCurve: function() {
       var self = this
       if (!this.currentMemberId) return
-      var reportType = this.activeReportType === 'food_recommend' ? null : this.activeReportType
+      // food_recommend 无时序数据:FoodItem 为评分型数据,NormalizeIndicatorPipeline 不写 indicator_values,
+      // 此处回退为全量指标参与曲线/排名(有意为之,非 bug)
+      var reportType = (!this.activeReportType || this.activeReportType === 'food_recommend') ? null : this.activeReportType
       getIndicatorChangeRanking({
         subjectId: this.currentMemberId,
         reportType: reportType,
@@ -319,6 +344,18 @@ export default {
   font-size: 26rpx;
   color: #94A3B8;
 }
+.rank-gap {
+  display: inline-block;
+  margin: 4rpx 0;
+  color: #e53e3e;
+  font-size: 20rpx;
+}
+.rank-empty {
+  padding: 60rpx 0;
+  text-align: center;
+  font-size: 24rpx;
+  color: #94A3B8;
+}
 
 /* 排名 Tab */
 .rank-tabs {

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

@@ -1,5 +1,7 @@
 <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">
@@ -99,17 +101,10 @@
     <!-- ② 发现区 — 上传报告 -->
 
     <FamilyFeed
-      :items="familyFeed"
+      :items="feedItems"
       :notices="notices"
-      :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>
+      :showHeader="true"
+      @viewed="onFeedItemViewed" />
 
     <!-- ④ 内容区 — 推荐文章 -->
 
@@ -430,7 +425,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, 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, readNotification, 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'
@@ -462,6 +457,9 @@ components: {
     return {
       currentRole: _currentRole,
 
+      // 已查看的动态提醒键(点击后从动态中移除;持久化,重启/刷新后不再出现)
+      viewedFeedKeys: (uni.getStorageSync('viewedFeedKeys') || []).slice(0, 200),
+
       // splash loading overlay(冷启动时展示,数据加载完关闭)
       splashOverlay: _isFirstLaunch,
       splashOverlayFading: false,
@@ -482,7 +480,7 @@ components: {
       gradientColors: [
         'linear-gradient(135deg, #10B981, #34D399)',
         'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
-        'linear-gradient(135deg, #8B5CF6, #C084FC)',
+        'linear-gradient(135deg, #F59E0B, #FBBF24)',
         'linear-gradient(135deg, #F97316, #FB923C)',
         'linear-gradient(135deg, #6366F1, #818CF8)'
       ],
@@ -527,15 +525,10 @@ membershipDays: 0,
       appUsage: {},
       quickLoaded: false,
 
-      // 能量/积分/CF值变更通知
+      // 能量/积分/CF值变更记录(合并展示于首页动态提醒,带具体值)
       energyMessages: [],
       pointsMessages: [],
-      cfMessages: [],
-      showEnergyBadge: false,
-      showPointsBadge: false,
-      showCfBadge: false,
-      // 通知栏可见性(从下往上滑入)
-      isNotificationVisible: false
+      cfMessages: []
 
 
     }
@@ -693,36 +686,100 @@ title: '添加家庭成员',
         color: 'gray'
       }
     },
-    // ===== 能量/积分/CF值变更通知格式化(标题带具体数量) =====
-    formatEnergyNotification: function() {
-      var total = 0
-      var arr = this.energyMessages || []
-      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
-    },
-    formatPointsNotification: function() {
-      var total = 0
-      var arr = this.pointsMessages || []
-      for (var i = 0; i < arr.length; i++) {
-        total += (arr[i].amount || 0)
+    // ===== 首页动态提醒列表(含具体值)=====
+    // 顺序:今日能量/积分/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 || ''
+          })
+        }
       }
-      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)
+      // 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 : 'CF值变动'
-      return '💰 CF值 ' + sign + total + ' · ' + first
+      // 已查看过的动态不再展示(点击后从提醒中移除),只保留前 20 条
+      var viewed = this.viewedFeedKeys || []
+      return list.filter(function(item) {
+        return !item._feedKey || viewed.indexOf(item._feedKey) === -1
+      }).slice(0, 20)
     }
   },
   onLoad(options) {
@@ -994,7 +1051,7 @@ title: '添加家庭成员',
           var gradientColors = [
             'linear-gradient(135deg, #10B981, #34D399)',
             'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
-            'linear-gradient(135deg, #8B5CF6, #C084FC)',
+            'linear-gradient(135deg, #F59E0B, #FBBF24)',
             'linear-gradient(135deg, #F97316, #FB923C)',
             'linear-gradient(135deg, #6366F1, #818CF8)'
           ]
@@ -1079,9 +1136,15 @@ 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'
+            type: 'task',
+            // 补充详情字段(首页动态提醒点击弹窗使用)
+            who: child.nickname || '小名',
+            taskTitle: t.title || '任务',
+            points: points,
+            pointsText: '+' + points + '分'
           }
         })
         self.familyFeed = feed.slice(0, 10)
@@ -1108,9 +1171,15 @@ title: '添加家庭成员',
               notice: n
             }
           })
-          self.notices = notices
+          // 已查看过的公告不再进入动态(历史消息页仍可查)
+          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
+          })
           // 合并 notices 到 familyFeed(优先显示)
-          self.familyFeed = notices.concat(self.familyFeed).slice(0, 10)
+          self.familyFeed = self.notices.concat(self.familyFeed).slice(0, 10)
         }
       }).catch(function() {})
     },
@@ -1135,13 +1204,39 @@ title: '添加家庭成员',
               notice: n
             }
           })
+          // 只将「未读」通知合并到动态(已读通知归入历史消息页,不再回到动态)
           var unread = notifications.filter(function(n) { return n.unread })
-          var read = notifications.filter(function(n) { return !n.unread })
-          // 未读在前,已读在后,合并到 familyFeed 顶部
-          self.familyFeed = unread.concat(read, self.familyFeed).slice(0, 10)
+          self.familyFeed = unread.concat(self.familyFeed).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
@@ -1151,9 +1246,6 @@ title: '添加家庭成员',
         self.energyMessages = []
         self.pointsMessages = []
         self.cfMessages = []
-        self.showEnergyBadge = false
-        self.showPointsBadge = false
-        self.showCfBadge = false
         return
       }
       
@@ -1170,30 +1262,21 @@ 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
       })
     },
     
@@ -1254,12 +1337,8 @@ title: '添加家庭成员',
             }
           }
           
-          // 存储到实例属性(供其他方法使用,或可单独暴露)
-          // 注意:这里的 messages 仅在该方法调用后有效,实际建议使用单独的数据存储
+          // 存储到实例属性(合并展示于首页动态提醒)
           self.energyMessages = messages
-          self.energyChangeTotal = totalAmount
-          self.energyChangeCount = count
-          self.showEnergyBadge = count > 0
         }
       }).catch(function() {})
     },
@@ -1324,9 +1403,6 @@ title: '添加家庭成员',
           }
           
           self.pointsMessages = messages
-          self.pointsChangeTotal = totalAmount
-          self.pointsChangeCount = count
-          self.showPointsBadge = count > 0
         }
       }).catch(function() {})
     },
@@ -1386,9 +1462,6 @@ title: '添加家庭成员',
               }
               
               self.cfMessages = messages
-              self.cfChangeTotal = totalAmount
-              self.cfChangeCount = count
-              self.showCfBadge = count > 0
             }
           }).catch(function() {})
         }
@@ -1410,43 +1483,14 @@ title: '添加家庭成员',
              date.getMonth() === today.getMonth() &&
              date.getFullYear() === today.getFullYear()
     },
-    
-    // ===== 能量/积分/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 = []
+// ===== 时间格式化 =====
+    // 时间格式化 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())
     },
     quickCompleteTask: function(task) {
       var self = this
@@ -1830,7 +1874,7 @@ title: '添加家庭成员',
           var gradientColors = [
             'linear-gradient(135deg, #10B981, #34D399)',
             'linear-gradient(135deg, #5B9BD5, #8FC5E8)',
-            'linear-gradient(135deg, #8B5CF6, #C084FC)',
+            'linear-gradient(135deg, #F59E0B, #FBBF24)',
             'linear-gradient(135deg, #F97316, #FB923C)',
             'linear-gradient(135deg, #6366F1, #818CF8)'
           ]
@@ -1870,7 +1914,11 @@ title: '添加家庭成员',
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
     },
     handleLogin() {
-      uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/index-home/index') })
+      this.openLoginSheet({ redirect: '/pages/index-home/index' })
+    },
+    // 登录成功:刷新首页登录态数据
+    onLoginSuccess() {
+      this.loadLoggedInData()
     },
     async handleParentInvite(code) {
       // 延迟显示,等待登录完成
@@ -2332,7 +2380,7 @@ title: '添加家庭成员',
 }
 .section-more {
   font-size: 24rpx;
-  color: #5B9BD5;
+  color: #F97316;
 }
 .empty-state {
   display: flex;
@@ -2659,7 +2707,7 @@ title: '添加家庭成员',
 /* ===== ① 了解区 — 自测大卡(引导"从了解开始") ===== */
 .self-test-card {
   margin: 20rpx 30rpx 16rpx;
-  background: linear-gradient(135deg, #667eea, #764ba2);
+  background: linear-gradient(135deg, #F97316, #FB923C);
   border-radius: 20rpx;
   padding: 24rpx 28rpx;
 }
@@ -2727,7 +2775,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, #8B5CF6, #A78BFA); color: #fff; }
+.guide-bar-purple .guide-bar-btn { background: linear-gradient(135deg, #F97316, #FB923C); 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); }
@@ -2861,41 +2909,4 @@ 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;

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

@@ -1,5 +1,7 @@
 <template>
   <view class="page">
+    <!-- 通用登录浮层 -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- 加载中 -->
     <view class="loading" v-if="loading">
       <view class="loading-icon">⏳</view>
@@ -294,18 +296,22 @@ export default {
       uni.reLaunch({ url: '/pages/index-home/index' })
     },
 
-    // 登录页跳转(带上邀请码)
+    // 打开登录浮层(家庭邀请码已存 pendingFamilyInviteCode,登录成功后走家庭邀请流程)
     goLogin() {
-      uni.navigateTo({ 
-        url: '/pages/login/login?familyInviteCode=' + this.familyInviteCode 
-      })
+      this.openLoginSheet()
     },
 
-    // 推广码场景:去登录
+    // 推广码场景:打开登录浮层(refCode 交给登录浮层静默绑定)
     goLoginWithRef() {
-      uni.navigateTo({ 
-        url: '/pages/login/login?refCode=' + this.refCode 
-      })
+      this.openLoginSheet({ refCode: this.refCode })
+    },
+
+    // 登录成功:刷新登录态并重新校验邀请
+    onLoginSuccess() {
+      this.checkLoginStatus()
+      if (this.familyInviteCode) {
+        this.validateFamilyInvite(this.familyInviteCode)
+      }
     },
 
     // 推广码绑定

+ 26 - 517
cfc-frontend/pages/login/login.vue

@@ -1,540 +1,49 @@
 <template>
-  <view class="login-container">
-    <!-- 顶部品牌区 -->
-    <view class="brand-section">
-      <image class="logo" src="/static/logo.png" mode="aspectFit" @error="handleImageError"></image>
-      <text class="app-name">浠艾福</text>
-      <text class="identity">您的专属家庭主动健康管理师</text>
-      <text class="slogan">家里每个人的状态,你真的看见了?</text>
-    </view>
-
-    <!-- 主动健康理念 + 闭环 -->
-    <view class="concept-card">
-      <view class="concept-desc">
-        不等问题发生,先一步<text class="concept-highlight">识别风险 · 主动干预 · 持续评估</text>,把全家健康管理融入每一天。
-      </view>
-      <view class="loop-row">
-        <view class="loop-pill loop-pill-indigo"><text>🔍</text><text class="loop-pill-text">提前识别</text></view>
-        <text class="loop-arrow">→</text>
-        <view class="loop-pill loop-pill-green"><text>🛡️</text><text class="loop-pill-text">风险管控</text></view>
-        <text class="loop-arrow">→</text>
-        <view class="loop-pill loop-pill-amber"><text>⚡</text><text class="loop-pill-text">主动干预</text></view>
-        <text class="loop-arrow">→</text>
-        <view class="loop-pill loop-pill-red"><text>📊</text><text class="loop-pill-text">效果评估</text></view>
-      </view>
-    </view>
-
-    <!-- 五维标签 -->
-    <view class="dims-row">
-      <view class="dim-tag dim-tag-body"><text>🌏</text><text>身·健康</text></view>
-      <view class="dim-tag dim-tag-wisdom"><text>⚡</text><text>智·赋能</text></view>
-      <view class="dim-tag dim-tag-wealth"><text>💧</text><text>富·创富</text></view>
-      <view class="dim-tag dim-tag-action"><text>🌿</text><text>行·和谐</text></view>
-      <view class="dim-tag dim-tag-mind"><text>🔥</text><text>心·大爱</text></view>
-    </view>
-
-    <!-- 登录表单 -->
-    <view class="login-form">
-      <view class="phone-section">
-        <!--
-          disabled 必须同时受 agreed 和 loading 约束。
-          open-type="getPhoneNumber" 在「点击那一刻」就由渲染层向微信发起授权请求,
-          早于 @getphonenumber 回调执行 —— 也就是说每一次点击都会消耗一次平台调用配额,
-          JS 里的 if (!this.agreed) return 只能弹 toast,拦不住已经发生的调用。
-          未同意协议时按钮若仍可点,用户会因「点了没反应」而反复点击,最终触发微信限频:
-          [渲染层错误] invoke getPhoneNumber too frequently。
-          故未勾选协议时直接禁用按钮,让无效点击根本不会发生。
-        -->
-        <button
-          class="phone-btn"
-          open-type="getPhoneNumber"
-          @getphonenumber="getPhoneNumber"
-          @tap="onPhoneBtnTap"
-          :loading="loading"
-          :disabled="!agreed || loading || tapLock"
-        >
-          <text class="phone-btn-icon">📱</text>
-          <text>手机号快捷登录</text>
-        </button>
-      </view>
-
-      <!-- 暂不登录 -->
-      <view class="skip-section">
-        <text class="skip-btn" @click="handleSkipLogin">暂不登录,先逛逛</text>
-      </view>
-
-      <view class="agreement">
-        <checkbox-group @change="onAgreementChange">
-          <label>
-            <checkbox value="agree" :checked="agreed" />
-            <text class="agreement-text">我已阅读并同意</text>
-          </label>
-        </checkbox-group>
-        <text class="link" @click="showAgreement">《用户协议》</text>
-        <text class="agreement-text">和</text>
-        <text class="link" @click="showPrivacy">《隐私政策》</text>
-      </view>
-    </view>
+  <view class="login-page">
+    <!--
+      兜底整页登录壳:全部登录逻辑已迁移至全局组件 login-sheet(components/login-sheet.vue)。
+      历史整页登录的 URL 参数(?inviteCode=&inviteType=&refCode=&redirect=)在 onLoad 透传给浮层。
+      standalone 模式语义:登录成功(无跳转需求)/ 暂不登录 → 回首页,保持旧整页登录行为。
+      触发场景:登出流程的 reLaunch 到本页、或宿主页面未挂载浮层时的 openLoginSheet() 回退。
+    -->
+    <login-sheet ref="loginSheet" :standalone="true" />
   </view>
 </template>
 
 <script>
-import { wechatPhoneLogin, checkTeamInviteStatus, joinTeamByInvite, bindReferralIfNotSelf } from '../../utils/api.js'
-
 export default {
   data() {
     return {
-      loading: false,
-      // tapLock:在「点击」瞬间上锁,用于关闭授权弹窗弹出前的连点窗口(见 onPhoneBtnTap)
-      tapLock: false,
-      agreed: false,
-      nickname: '',
-      avatar: '',
-      redirectUrl: ''
+      _sheetOptions: {}
     }
   },
   onLoad(options) {
-      // 处理邀请码
-      if (options.inviteCode) {
-        uni.setStorageSync('inviteCode', options.inviteCode)
-        uni.setStorageSync('inviteType', options.inviteType || 'family')
-      }
-      // 处理推荐码(refCode):来自 join.vue 的推广绑定流程,推荐码统一存 refCode storage
-      if (options.refCode) {
-        uni.setStorageSync('refCode', options.refCode)
-      }
-      // 存储登录后跳转地址
-      if (options.redirect) {
-        this.redirectUrl = decodeURIComponent(options.redirect)
-      }
-      // 注意:不在 onLoad 预取 login code —— 微信 code 一次性有效(5分钟且用过即废),
-      // 预取后如果用户隔几分钟再点击授权,code 已失效会报 invalid code (40029)。
-      // 正确做法:getPhoneNumber 中用户点击授权时实时获取。
-    },
-  onUnload() {
-    // 清理 tap 锁兜底定时器,避免登录成功 reLaunch 后定时器仍在已销毁页面上置值
-    if (this._tapLockTimer) {
-      clearTimeout(this._tapLockTimer)
-      this._tapLockTimer = null
+    this._sheetOptions = {
+      inviteCode: (options && options.inviteCode) || '',
+      inviteType: (options && options.inviteType) || '',
+      refCode: (options && options.refCode) || '',
+      redirect: (options && options.redirect) || ''
     }
   },
-  methods: {
-    handleImageError(e) {
-      console.log('Logo 加载失败,使用默认显示')
-    },
-    onAgreementChange(e) {
-      this.agreed = e.detail.value.length > 0
-    },
-    showAgreement() {
-      uni.navigateTo({
-        url: '/pages/policy/agreement'
-      })
-    },
-    showPrivacy() {
-      uni.navigateTo({
-        url: '/pages/policy/privacy'
-      })
-    },
-    handleSkipLogin() {
-      // 不登录直接进入首页
-      uni.switchTab({ url: '/pages/index-home/index' })
-    },
-    // 点击瞬间上锁,关闭「授权弹窗弹出前」的连点窗口。
-    //
-    // 为什么不能只在 getPhoneNumber 里判 loading:
-    // open-type="getPhoneNumber" 在点击那一刻就由渲染层向微信发起了授权调用,
-    // @getphonenumber 回调要等用户在系统弹窗里点完才返回。这中间几百毫秒内
-    // loading 还是 false、按钮仍可点,用户「以为没反应」就连点,
-    // 每次点击都真实消耗一次平台调用配额 → invoke getPhoneNumber too frequently。
-    // 在 @tap 上锁能让按钮在一个渲染周期内变为不可点,早于弹窗出现。
-    onPhoneBtnTap() {
-      if (this.loading || this.tapLock) return
-      this.tapLock = true
-      // 兜底:若因系统异常导致 getphonenumber 始终不回调,3 秒后强制解锁,
-      // 避免按钮永久锁死导致无法再登录。
-      if (this._tapLockTimer) clearTimeout(this._tapLockTimer)
-      this._tapLockTimer = setTimeout(() => {
-        this.tapLock = false
-        this._tapLockTimer = null
-      }, 3000)
-    },
-    getPhoneNumber: function(e) {
-      // 授权流程已结束,释放 tap 锁与兜底定时器,按钮恢复可点。
-      // 注意:这里原有的 `if (this.loading) return` 已移除 —— loading 在本函数内才置 true,
-      // 保留它会把首个合法点击也拦掉;防重复消费改由下面的 tapLock 承担。
-      if (this._tapLockTimer) {
-        clearTimeout(this._tapLockTimer)
-        this._tapLockTimer = null
-      }
-      this.tapLock = false
-
-      if (!this.agreed) {
-        uni.showToast({ title: '请先同意用户协议', icon: 'none' })
-        return
-      }
-      if (!e.detail || !e.detail.code) {
-        uni.showToast({ title: '授权失败,请重试', icon: 'none' })
-        return
-      }
-      var self = this
-      self.loading = true
-      // 点击授权时实时获取 login code(微信 code 一次性有效,不能复用旧 code)
-      uni.login({
-        provider: 'weixin',
-        success: function(loginRes) {
-          if (!loginRes.code) {
-            uni.showToast({ title: '网络异常,请重试', icon: 'none' })
-            self.loading = false
-            return
-          }
-          wechatPhoneLogin({ code: loginRes.code, phoneCode: e.detail.code }).then(function(res) {
-            self.completeLogin(res.data)
-          }).catch(function(err) {
-            uni.showToast({ title: err.message || '登录失败', icon: 'none' })
-            self.loading = false
-          })
-        },
-        fail: function() {
-          uni.showToast({ title: '网络异常,请重试', icon: 'none' })
-          self.loading = false
-        }
-      })
-    },
-    completeLogin: function(data) {
-      var self = this
-      uni.setStorageSync('token', data.token)
-      uni.setStorageSync('userId', data.userId)
-      uni.setStorageSync('role', data.role)
-      uni.setStorageSync('currentRole', data.role)
-      uni.setStorageSync('isSwitchedChild', false)
-      uni.setStorageSync('isSwitchedTeacher', false)
-      if (data.openid) uni.setStorageSync('openid', data.openid)
-      // familyId 必须持久化:家庭在注册时已由后端创建,缺失会导致页面误判无家庭而重复创建
-      uni.setStorageSync('userInfo', { nickname: data.nickname, userId: data.userId, familyId: data.familyId, vendorType: data.vendorType, activityProviderStatus: data.activityProviderStatus })
-      if (data.phone) uni.setStorageSync('phone', data.phone)
-      if (data.teacherStatus) uni.setStorageSync('teacherStatus', data.teacherStatus)
-      if (data.teacherRejectReason) uni.setStorageSync('teacherRejectReason', data.teacherRejectReason)
-      self.$store.commit('setFamilyId', data.familyId)
-      uni.showToast({ title: '登录成功', icon: 'success' })
-      self.loading = false
-      // 注册零填写:新用户(needsRoleSelect)不再跳信息填写页,直接进首页
-      // 家庭邀请衔接:有 pendingFamilyInviteCode(join.vue 写入)→ 直接进 join-form 填真实姓名+家庭身份
-      var pendingInvite = uni.getStorageSync('pendingFamilyInviteCode')
-      if (pendingInvite) {
-        uni.redirectTo({ url: '/pages/invite/join-form?familyInviteCode=' + encodeURIComponent(pendingInvite) })
-      } else {
-        self.handlePostLoginRouting()
-      }
-    },
-    handlePostLoginRouting() {
-      const inviteCode = uni.getStorageSync('inviteCode')
-      const inviteType = uni.getStorageSync('inviteType')
-      const refCode = uni.getStorageSync('refCode')
-
-      if (inviteCode && inviteType === 'team') {
-        this.handleTeamInviteFlow(inviteCode)
-      } else if (refCode) {
-        this.handleReferralBind(refCode)
-      } else if (inviteCode) {
-        // 兼容:历史 storage inviteCode 承载推荐码的残留
-        this.handleReferralBind(inviteCode)
-      } else {
-        this.navigateToHome()
-      }
-    },
-    async handleReferralBind(refCode) {
-      try {
-var res = await bindReferralIfNotSelf(refCode)
-        if (res && res.code === 200) {
-          console.log('登录后绑定推荐人成功', refCode)
-        }
-      } catch (e) {
-        // 后端 Result.error(code=500) → request() 已 toast「已绑定推荐人,不可修改」等业务消息
-        if (e && e.code !== undefined && e.code !== 401) {
-          console.log('登录后推荐码业务拒绝', refCode, e.message || '')
-        } else {
-          console.log('登录后推荐码绑定失败', e)
-        }
-      }
-      uni.removeStorageSync('refCode')
-      uni.removeStorageSync('inviteCode')
-      uni.removeStorageSync('inviteType')
-      this.navigateToHome()
-    },
-    async handleTeamInviteFlow(inviteCode) {
-      try {
-        const res = await checkTeamInviteStatus(inviteCode)
-        const status = res.data.status
-
-        if (status === 'not_planner') {
-          uni.redirectTo({ url: `/pages/guide/register/index?inviteCode=${inviteCode}` })
-        } else if (status === 'not_in_team') {
-          uni.showModal({
-            title: '加入团队',
-            content: '是否加入该规划师团队?',
-            success: async (confirmRes) => {
-              if (confirmRes.confirm) {
-                try {
-                  await joinTeamByInvite(inviteCode)
-                  uni.showToast({ title: '加入成功', icon: 'success' })
-                  uni.removeStorageSync('inviteCode')
-                  uni.removeStorageSync('inviteType')
-                  this.navigateToHome()
-                } catch (e) {
-                  uni.showToast({ title: e.message || '加入失败', icon: 'none' })
-                }
-              } else {
-                this.navigateToHome()
-              }
-            }
-          })
-        } else if (status === 'already_in_team') {
-          uni.showModal({
-            title: '加入新团队',
-            content: '您已在其他团队中,离开当前团队并加入新团队?',
-            success: async (confirmRes) => {
-              if (confirmRes.confirm) {
-                try {
-                  await joinTeamByInvite(inviteCode)
-                  uni.showToast({ title: '加入成功', icon: 'success' })
-                  uni.removeStorageSync('inviteCode')
-                  uni.removeStorageSync('inviteType')
-                  this.navigateToHome()
-                } catch (e) {
-                  uni.showToast({ title: e.message || '加入失败', icon: 'none' })
-                }
-              } else {
-                this.navigateToHome()
-              }
-            }
-          })
-        } else {
-          this.navigateToHome()
-        }
-      } catch (e) {
-        uni.showToast({ title: e.message || '处理邀请失败', icon: 'none' })
-        this.navigateToHome()
-      }
-    },
-    navigateToHome() {
-      if (this.redirectUrl) {
-        const redirect = this.redirectUrl
-        this.redirectUrl = ''
-        // 注意: redirectTo 不支持跳转 tabBar 页面,必须用 switchTab
-        // 只有 pages.json tabBar.list 注册的页面才能用 switchTab
-        var tabBarPages = ['/pages/index-home/index', '/pages/wealth/index']
-        if (tabBarPages.indexOf(redirect) !== -1) {
-          uni.switchTab({ url: redirect })
-        } else {
-          uni.redirectTo({ url: redirect })
-        }
-      } else {
-        uni.switchTab({ url: '/pages/index-home/index' })
-      }
+  mounted() {
+    // 页面渲染完成后自动弹出浮层(子组件先于父组件 mounted,$refs 可用)
+    if (this.$refs.loginSheet) {
+      this.$refs.loginSheet.open(this._sheetOptions)
+    }
+  },
+  onUnload() {
+    // 清理 tap 锁兜底定时器,避免登录成功跳转后定时器仍在已销毁组件上置值(组件自身也会清理)
+    if (this.$refs && this.$refs.loginSheet && this.$refs.loginSheet._tapLockTimer) {
+      clearTimeout(this.$refs.loginSheet._tapLockTimer)
+      this.$refs.loginSheet._tapLockTimer = null
     }
   }
 }
 </script>
 
 <style scoped>
-.login-container {
+.login-page {
   min-height: 100vh;
   background: linear-gradient(180deg, #FFF9F0 0%, #FFEEDB 100%);
-  padding: 40rpx 36rpx 32rpx;
-  display: flex;
-  flex-direction: column;
-}
-
-/* ===== 顶部品牌区 ===== */
-.brand-section {
-  text-align: center;
-  margin-bottom: 32rpx;
-}
-
-.logo {
-  width: 104rpx;
-  height: 104rpx;
-  border-radius: 52rpx;
-  background: #fff;
-  box-shadow: 0 8rpx 24rpx rgba(249,115,22,0.2);
-}
-
-.app-name {
-  display: block;
-  font-size: 44rpx;
-  font-weight: bold;
-  color: #7C2D12;
-  margin-top: 14rpx;
-  letter-spacing: 6rpx;
-}
-
-.identity {
-  display: block;
-  font-size: 26rpx;
-  font-weight: 600;
-  color: #C2410C;
-  margin-top: 10rpx;
-}
-
-.slogan {
-  display: block;
-  font-size: 24rpx;
-  color: #9A3412;
-  margin-top: 8rpx;
-}
-
-/* ===== 主动健康理念 + 闭环 ===== */
-.concept-card {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 26rpx 28rpx;
-  margin-bottom: 24rpx;
-  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.08);
-}
-
-.concept-desc {
-  font-size: 24rpx;
-  color: #4B5563;
-  line-height: 1.6;
-  margin-bottom: 20rpx;
-}
-
-.concept-highlight {
-  color: #B45309;
-  font-weight: 600;
-}
-
-.loop-row {
-  display: flex;
-  align-items: center;
-  gap: 6rpx;
-}
-
-.loop-pill {
-  flex: 1;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 4rpx;
-  flex-direction: column;
-  font-size: 20rpx;
-  padding: 10rpx 4rpx;
-  border-radius: 12rpx;
-  line-height: 1.3;
-}
-
-.loop-pill-text {
-  font-size: 20rpx;
-  font-weight: 500;
-}
-
-.loop-pill-indigo { background: #EEF2FF; color: #4F46E5; }
-.loop-pill-green { background: #D1FAE5; color: #047857; }
-.loop-pill-amber { background: #FEF3C7; color: #B45309; }
-.loop-pill-red { background: #FEE2E2; color: #B91C1C; }
-
-.loop-arrow {
-  font-size: 24rpx;
-  color: #D1D5DB;
-  flex-shrink: 0;
-}
-
-/* ===== 五维标签 ===== */
-.dims-row {
-  display: flex;
-  justify-content: center;
-  gap: 8rpx;
-  margin-bottom: 24rpx;
-  flex-wrap: wrap;
-}
-
-.dim-tag {
-  display: flex;
-  align-items: center;
-  gap: 4rpx;
-  font-size: 20rpx;
-  color: #fff;
-  font-weight: 500;
-  padding: 8rpx 14rpx;
-  border-radius: 24rpx;
-  white-space: nowrap;
-}
-
-.dim-tag-body { background: #F97316; }
-.dim-tag-wisdom { background: #6366F1; }
-.dim-tag-wealth { background: #D97706; }
-.dim-tag-action { background: #10B981; }
-.dim-tag-mind { background: #EC4899; }
-
-/* ===== 登录表单 ===== */
-.login-form {
-  background: #fff;
-  border-radius: 20rpx;
-  padding: 36rpx 36rpx 28rpx;
-  margin-top: 48rpx;
-  box-shadow: 0 4rpx 24rpx rgba(0,0,0,0.06);
-}
-
-.phone-section {
-  margin-bottom: 24rpx;
-}
-
-.phone-btn {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 12rpx;
-  width: 100%;
-  height: 88rpx;
-  background: #07c160;
-  color: #fff;
-  border-radius: 44rpx;
-  font-size: 32rpx;
-  font-weight: 500;
-  border: none;
-}
-
-.phone-btn::after {
-  border: none;
-}
-
-.phone-btn-icon {
-  font-size: 36rpx;
-}
-
-/* ===== 跳过登录 ===== */
-.skip-section {
-  text-align: center;
-  margin-bottom: 20rpx;
-}
-
-.skip-btn {
-  font-size: 26rpx;
-  color: #F97316;
-  font-weight: 500;
-  padding: 10rpx 20rpx;
-  background: rgba(249, 115, 22, 0.08);
-  border-radius: 30rpx;
-}
-
-/* ===== 协议 ===== */
-.agreement {
-  margin-top: 4rpx;
-  text-align: center;
-  font-size: 22rpx;
-  line-height: 1.6;
-}
-
-.agreement checkbox {
-  transform: scale(0.8);
-}
-
-.agreement-text {
-  color: #9CA3AF;
-}
-
-.link {
-  color: #5B9BD5;
-  text-decoration: underline;
 }
 </style>

+ 3 - 0
cfc-frontend/pages/login/scan-login.json

@@ -0,0 +1,3 @@
+{
+  "navigationBarTitleText": "扫码登录"
+}

+ 172 - 0
cfc-frontend/pages/login/scan-login.vue

@@ -0,0 +1,172 @@
+<template>
+  <view class="page">
+    <view class="card">
+      <view class="icon">{{ icon }}</view>
+      <text class="title">{{ title }}</text>
+      <text class="desc">{{ desc }}</text>
+
+      <button
+        v-if="status === 'needlogin'"
+        class="btn btn-primary"
+        @tap="goLogin"
+      >微信一键登录</button>
+
+      <button
+        v-if="status === 'error'"
+        class="btn btn-primary"
+        @tap="tryScan"
+      >重试</button>
+
+      <button
+        v-if="status === 'success'"
+        class="btn btn-primary"
+        @tap="goHome"
+      >进入小程序首页</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { wechatWebScan } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      state: '',
+      status: 'loading', // loading / success / needlogin / error
+      message: ''
+    }
+  },
+  computed: {
+    icon() {
+      if (this.status === 'success') return '✅'
+      if (this.status === 'error') return '❌'
+      if (this.status === 'needlogin') return '🔑'
+      return '⏳'
+    },
+    title() {
+      if (this.status === 'success') return '登录成功'
+      if (this.status === 'error') return '登录失败'
+      if (this.status === 'needlogin') return '需要先登录'
+      return '正在确认登录…'
+    },
+    desc() {
+      if (this.status === 'success') return '已与电脑端同步登录,请返回电脑端客户端继续'
+      if (this.status === 'error') return this.message || '二维码无效或已过期,请在电脑端重新获取'
+      if (this.status === 'needlogin') return '请先登录小程序,登录后将自动完成电脑端登录'
+      return '请稍候'
+    }
+  },
+  onLoad(query) {
+    this._onLoadFired = true
+    query = query || {}
+    var state = query.state || ''
+    if (!state && query.scene) {
+      // 小程序码 scene 为 URL 编码串
+      try {
+        state = decodeURIComponent(query.scene)
+      } catch (e) {
+        state = query.scene
+      }
+    }
+    this.state = state || ''
+    this.tryScan()
+  },
+  onShow() {
+    // 首次进入:onLoad 已处理;从登录页返回时重新尝试
+    if (this._onLoadFired) {
+      this._onLoadFired = false
+      return
+    }
+    if (this.state && this.status !== 'success') {
+      this.tryScan()
+    }
+  },
+  methods: {
+    tryScan() {
+      if (!this.state) {
+        this.status = 'error'
+        this.message = '二维码缺少参数,请在电脑端重新获取'
+        return
+      }
+      var token = uni.getStorageSync('token')
+      if (!token) {
+        this.status = 'needlogin'
+        return
+      }
+      this.status = 'loading'
+      var self = this
+      wechatWebScan(this.state).then(function (res) {
+        if (res && res.code === 200) {
+          self.status = 'success'
+        } else {
+          self.status = 'error'
+          self.message = (res && res.message) || '登录失败'
+        }
+      }).catch(function (e) {
+        self.status = 'error'
+        self.message = (e && e.message) || '网络异常,请重试'
+      })
+    },
+    goLogin() {
+      uni.navigateTo({ url: '/pages/login/login' })
+    },
+    goHome() {
+      uni.switchTab({ url: '/pages/index/index' })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  padding: 40rpx;
+  min-height: 100vh;
+  background: linear-gradient(135deg, #FFF7ED 0%, #FEF3C7 100%);
+  display: flex;
+  align-items: flex-start;
+  justify-content: center;
+}
+.card {
+  width: 100%;
+  max-width: 600rpx;
+  margin-top: 16vh;
+  background: #FFFFFF;
+  border-radius: 24rpx;
+  padding: 60rpx 40rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.08);
+}
+.icon {
+  font-size: 96rpx;
+  margin-bottom: 24rpx;
+}
+.title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #1E293B;
+  margin-bottom: 16rpx;
+}
+.desc {
+  font-size: 28rpx;
+  color: #64748B;
+  text-align: center;
+  line-height: 1.6;
+  margin-bottom: 40rpx;
+}
+.btn {
+  width: 100%;
+  padding: 28rpx;
+  border-radius: 16rpx;
+  font-size: 30rpx;
+  font-weight: 600;
+  text-align: center;
+  border: none;
+}
+.btn-primary {
+  background: linear-gradient(135deg, #F97316, #EA580C);
+  color: #FFFFFF;
+}
+</style>

+ 51 - 13
cfc-frontend/pages/mind-detail/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 通用登录浮层 -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- Tabbar 切换过渡页 -->
     <tab-transition v-if="showTabTransition" dimCode="mind" ref="tabTransition" @close="showTabTransition = false" />
 
@@ -37,6 +39,20 @@
       <text class="guidance-text">{{ monthlyGuidance }}</text>
     </view>
 
+    <!-- 游客价值卡(统一组件:维度色 CTA + 价值预览) -->
+    <DimensionVisitorCard
+      v-if="!isLoggedIn"
+      dimCode="mind"
+      card-title="心维度 · 心理成长"
+      :benefits="[
+        '心理测评 EQ 报告与情绪画像',
+        '情绪与肠胃关联解读',
+        '家庭关系质量分析'
+      ]"
+      cta-text="登录查看心理画像"
+      secondary-text="了解心理维度"
+      secondary-url="/pages/mind-detail/dimension-intro" />
+
         <!-- 登录后:五维能量条 -->
         <FamilyEnergyBar
           v-if="isLoggedIn"
@@ -217,6 +233,7 @@
 import TabTransition from '../../components/tab-transition.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
+import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import PsychCrisisBanner from '@/pages/mind-detail/components/PsychCrisisBanner.vue'
@@ -229,7 +246,7 @@ import config from '../../config.js'
 import { parseDate } from '../../utils/format.js'
 
 export default {
-components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, FamilyMemberStrip, DimensionIntroCard },
+components: { TabTransition, PageBanner, RadarChart, FamilyEnergyBar, PsychCrisisBanner, FloatingAvatar, FamilyTianpanCard, FamilyMemberStrip, DimensionIntroCard, DimensionVisitorCard },
   data() {
     return {
       isLoggedIn: false,
@@ -542,7 +559,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
         { key: 'conscientiousnessScore', label: '尽责性', color: '#F59E0B', desc: '做事有条理、自律程度' },
         { key: 'extraversionScore', label: '外向性', color: '#10B981', desc: '社交活跃与外向程度' },
         { key: 'agreeablenessScore', label: '宜人性', color: '#FF8C42', desc: '与人相处的和谐程度' },
-        { key: 'neuroticismScore', label: '神经质', color: '#8B5CF6', desc: '情绪波动程度(反向)' }
+        { key: 'neuroticismScore', label: '神经质', color: '#F97316', desc: '情绪波动程度(反向)' }
       ]
       var result = { overall: 0, tierLabel: '', summary: '', dims: [], tip: '' }
       var total = 0
@@ -866,7 +883,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
         uni.showModal({
           title: '提示',
           content: '请先登录后使用此功能',
-          success: function(res) { if (res.confirm) uni.navigateTo({ url: '/pages/login/login' }) }
+          success: (res) => { if (res.confirm) this.openLoginSheet() }
         })
         return
       }
@@ -1032,7 +1049,28 @@ var descList = descMap[this.dominantElement] || descMap.wood
       uni.pageScrollTo({ selector: '#' + section, duration: 300 })
     },
     goAssessment: function() { uni.navigateTo({ url: '/pages/assessment/apply' }) },
-    goLogin: function() { uni.navigateTo({ url: '/pages/login/login' }) },
+    goLogin: function() { this.openLoginSheet() },
+    // 登录成功:刷新登录态数据(镜像 onShow 已登录分支)
+    onLoginSuccess: function() {
+      this.isLoggedIn = true
+      this.role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || null
+      this.currentChildId = (this.$store.state && this.$store.state.currentChildId) || uni.getStorageSync('currentChildId') || null
+      this.userName = uni.getStorageSync('nickname') || uni.getStorageSync('userName') || '家长'
+      this.loadSectionConfig()
+      this.loadChildren()
+      this.loadFamilyMembersVisible()
+      this.loadSandboxData()
+      this.loadMindRadarPopulationData()
+      this.loadEmiData()
+      this.loadGutData()
+      this.loadEnergyData()
+      this.loadTraditionalCultureData()
+      this.loadContacts()
+      this.loadHealthAlerts()
+      this.loadMilestones()
+      this.loadDanReports()
+      this._watchPageReady()
+    },
 
     // ===== 传统文化数据 =====
     // ===== 联系人 / 关系健康 =====
@@ -1162,7 +1200,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   align-items: center;
   margin: 20rpx 30rpx 10rpx;
   padding: 16rpx 24rpx;
-  background: linear-gradient(135deg, #8B5CF6, #A78BFA);
+  background: linear-gradient(135deg, #F97316, #FB923C);
   border-radius: 20rpx;
   box-shadow: 0 4rpx 16rpx rgba(139,92,246,0.25);
 }
@@ -1235,7 +1273,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 }
 .mind-report-arrow {
   font-size: 36rpx;
-  color: #C084FC;
+  color: #FB923C;
   margin-left: 10rpx;
   flex-shrink: 0;
 }
@@ -1358,7 +1396,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   margin-bottom: 24rpx;
 }
 .test-btn {
-  background: #8B5CF6;
+  background: #F97316;
   padding: 8rpx 28rpx;
   border-radius: 30rpx;
 }
@@ -1646,7 +1684,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .bazi-pillar-value {
   font-size: 32rpx;
   font-weight: 700;
-  color: #8B5CF6;
+  color: #F97316;
   background: #F5F0FF;
   padding: 10rpx 14rpx;
   border-radius: 10rpx;
@@ -1830,7 +1868,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
 .culture-dual-value {
   font-size: 28rpx;
   font-weight: 700;
-  color: #8B5CF6;
+  color: #F97316;
 }
 .culture-empty {
   text-align: center;
@@ -2226,7 +2264,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   text-align: center;
   padding: 12rpx;
   font-size: 24rpx;
-  color: #8B5CF6;
+  color: #F97316;
   font-weight: 500;
   background: #F5F3FF;
   border-radius: 12rpx;
@@ -2303,7 +2341,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   padding: 20rpx 28rpx;
   background: linear-gradient(135deg, #FFF0F5, rgba(255,107,157,0.12));
   border-radius: 16rpx;
-  border-left: 4rpx solid #FF6B9D;
+  border-left: 8rpx solid #FF6B9D;
 }
 .brand-strap-text {
   font-size: 26rpx;
@@ -2317,7 +2355,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   padding: 20rpx 28rpx;
   background: linear-gradient(135deg, #FFF0F5, rgba(255,107,157,0.08));
   border-radius: 16rpx;
-  border-left: 4rpx solid #FF6B9D;
+  border-left: 8rpx solid #FF6B9D;
   display: flex;
   align-items: center;
   gap: 12rpx;
@@ -2417,7 +2455,7 @@ var descList = descMap[this.dominantElement] || descMap.wood
   padding: 16rpx 20rpx;
   background: linear-gradient(135deg, #FFF0F5, #FDF2F7);
   border-radius: 12rpx;
-  border-left: 4rpx solid #FF6B9D;
+  border-left: 8rpx solid #FF6B9D;
   margin-top: 16rpx;
 }
 .portrait-tip-text {

+ 11 - 8
cfc-frontend/pages/profile-extra/family-members.vue

@@ -106,8 +106,8 @@
           <button class="detail-action-btn action-questionnaire" @click="doQuestionnaire(detailMember)">关系问卷</button>
           <button class="detail-action-btn action-interaction" @click="recordInteraction(detailMember)">记录互动</button>
           <button class="detail-action-btn action-kick" @click="confirmKick(detailMember)" v-if="isAdmin && detailMember && !detailMember.isSelf">删除成员</button>
-        </view>
-      </view>
+       </view>
+     </view>
     </view>
 
     <!-- ===== 底部操作栏 ===== -->
@@ -138,9 +138,10 @@ import {
   rejectJoinRequest, reportAppEnter
 } from '../../utils/api.js'
 import store from '../../store/index.js'
+import GenerationPicker from '@/components/GenerationPicker.vue'
 
 export default {
-  components: {},
+  components: { GenerationPicker },
   data() {
     return {
       userId: 0,
@@ -160,8 +161,9 @@ export default {
 
       inviteToken: '',
       joinRequests: [],
-      shareCardImage: ''
-    }
+      shareCardImage: '',
+
+      }
   },
   computed: {
     isSelfView() {
@@ -189,7 +191,8 @@ export default {
     reportAppEnter('members');
     
     var userInfo = uni.getStorageSync('userInfo')
-    this.userId = (userInfo && userInfo.id) || 0
+    // storage 里的 key 是 userId(utils/api.js 设的是 userId),切勿写 .id(永远为 0)
+    this.userId = (userInfo && userInfo.userId) || (uni.getStorageSync('userId')) || 0
     this.userNickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || ''
     this.userAvatar = uni.getStorageSync('avatar') || (userInfo && userInfo.avatar) || ''
     // 家庭在用户注册时已由后端创建(登录接口另有兜底),此处不再触发建家庭逻辑;
@@ -1006,8 +1009,8 @@ export default {
   color: #67c23a;
 }
 .action-questionnaire {
-  background: #f3e8ff;
-  color: #8b5cf6;
+  background: #EEF0FF;
+  color: #6366F1;
 }
 .action-interaction {
   background: #fff7ed;

+ 12 - 6
cfc-frontend/pages/profile-main/profile.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- 未登录 -->
     <view v-if="!isLoggedIn">
       <view class="login-prompt">
@@ -690,7 +692,11 @@ export default {
       })
     },
     goLogin: function() {
-      uni.navigateTo({ url: '/pages/login/login' })
+      this.openLoginSheet()
+    },
+    // 登录浮层成功回调:刷新个人资料与登录后数据
+    onLoginSuccess: function() {
+      if (this.loadUserInfo) this.loadUserInfo()
     },
     goSkip: function() {
       uni.switchTab({ url: '/pages/index-home/index' })
@@ -840,7 +846,7 @@ export default {
 .prompt-title { font-size: 40rpx; font-weight: bold; color: #333; margin-bottom: 16rpx; }
 .prompt-desc { font-size: 26rpx; color: #999; margin-bottom: 20rpx; }
 .login-btn {
-  background: linear-gradient(135deg, #667eea, #764ba2);
+  background: linear-gradient(135deg, #F97316, #FB923C);
   color: #fff;
   border-radius: 50rpx;
   font-size: 30rpx;
@@ -876,7 +882,7 @@ export default {
 /* 退出切换 */
 .exit-switch {
   margin: 0 30rpx 16rpx;
-  background: linear-gradient(135deg, #667eea, #764ba2);
+  background: linear-gradient(135deg, #F97316, #FB923C);
   color: #fff;
   padding: 16rpx;
   border-radius: 12rpx;
@@ -887,7 +893,7 @@ export default {
 /* 身份卡 */
 .user-card {
   margin: 24rpx 30rpx 20rpx;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
   border-radius: 24rpx;
   padding: 32rpx 32rpx;
   display: flex;
@@ -1268,13 +1274,13 @@ export default {
   margin: 0 30rpx 20rpx;
 }
 .service-center-card {
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  background: linear-gradient(135deg, #F97316 0%, #FB923C 100%);
   border-radius: 20rpx;
   padding: 24rpx;
   display: flex;
   align-items: center;
   gap: 20rpx;
-  box-shadow: 0 4rpx 16rpx rgba(102,126,234,0.25);
+  box-shadow: 0 4rpx 16rpx rgba(249,115,22,0.25);
 }
 .service-center-card.disabled-card {
   background: linear-gradient(135deg, #e2e8f0, #94a3b8);

+ 7 - 3
cfc-frontend/pages/profile/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <view v-if="!isLoggedIn" class="login-prompt">
       <view class="prompt-icon">👤</view>
       <text class="prompt-title">登录浠艾福</text>
@@ -104,9 +106,11 @@ export default {
       uni.navigateTo({ url: '/pages/wealth/index' })
     },
     goLogin() {
-      uni.navigateTo({
-        url: '/pages/login/login'
-      })
+      this.openLoginSheet()
+    },
+    // 登录浮层成功回调:加载登录后数据(推荐码等)
+    onLoginSuccess() {
+      if (this.isLoggedIn) this.loadReferralCode()
     },
     goToEditInfo() {
       uni.navigateTo({

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

+ 82 - 241
cfc-frontend/pages/reminder-config/index.vue

@@ -1,31 +1,31 @@
-<template>
+<template>
   <view class="reminder-config-page">
     <scroll-view class="content" scroll-y>
-      <!-- 启用开关 -->
+      <!-- 鍚敤寮€鍏?-->
       <view class="cfg-card">
         <view class="cfg-card-header">
-          <text class="cfg-card-title">提醒开关</text>
+          <text class="cfg-card-title">鎻愰啋寮€鍏?/text>
           <text class="cfg-switch" :class="{ 'cfg-switch-on': enabled === 1 }" @tap="toggleEnabled">
-            <text class="cfg-switch-text">{{ enabled === 1 ? '已开启' : '已关闭' }}</text>
+            <text class="cfg-switch-text">{{ enabled === 1 ? '宸插紑鍚? : '宸插叧闂? }}</text>
           </text>
         </view>
-        <text class="cfg-card-desc">开启后,任务打卡未完成时会按以下设置发送提醒</text>
+        <text class="cfg-card-desc">寮€鍚悗锛屼换鍔℃墦鍗℃湭瀹屾垚鏃朵細鎸変互涓嬭缃彂閫佹彁閱?/text>
       </view>
 
-      <!-- 提醒渠道 -->
+      <!-- 鎻愰啋娓犻亾 -->
       <view class="cfg-card">
-        <text class="cfg-card-title">提醒渠道</text>
+        <text class="cfg-card-title">鎻愰啋娓犻亾</text>
         <view class="channel-list">
-          <view class="channel-item" v-for="ch in channels" :key="ch.type"
+          <view class="channel-item" v-for="(ch, index) in channels" :key="ch.type + '_' + index"
             :class="{
               'channel-selected': isSelected(ch.type),
               'channel-disabled': !ch.available
             }"
             @tap="toggleChannel(ch)">
-            <view class="channel-check" v-if="isSelected(ch.type) && ch.available">✓</view>
+            <view class="channel-check" v-if="isSelected(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>
+              <text class="channel-reason" v-if="!ch.available">{{ ch.reason || '鏈紑閫? }}</text>
             </view>
           </view>
           <view class="channel-tip" v-if="channelTip">
@@ -34,38 +34,63 @@
         </view>
       </view>
 
-      <!-- 提醒时机 -->
+      <!-- 鎻愰啋鏃舵満 -->
       <view class="cfg-card">
-        <text class="cfg-card-title">提醒时机</text>
+        <text class="cfg-card-title">鎻愰啋鏃舵満</text>
         <view class="timing-list">
           <view class="timing-item" :class="{ 'timing-active': timingMode === 'before_deadline' }" @tap="switchTiming('before_deadline')">
-            <text class="timing-name">任务截止前</text>
-            <text class="timing-desc">截止时间之前提醒</text>
+            <text class="timing-name">浠诲姟鎴鍓?/text>
+            <text class="timing-desc">鎴鏃堕棿涔嬪墠鎻愰啋</text>
           </view>
           <view class="timing-item" :class="{ 'timing-active': timingMode === 'after_timeout' }" @tap="switchTiming('after_timeout')">
-            <text class="timing-name">任务超时后</text>
-            <text class="timing-desc">截止时间之后提醒</text>
+            <text class="timing-name">浠诲姟瓒呮椂鍚?/text>
+            <text class="timing-desc">鎴鏃堕棿涔嬪悗鎻愰啋</text>
           </view>
         </view>
 
-        <!-- 偏移分钟 -->
+        <!-- 鍋忕Щ鍒嗛挓 -->
         <view class="offset-row">
           <text class="offset-label">{{ offsetLabel }}</text>
-          <input class="offset-input" type="number" :value="String(offsetMinutes)" @input="onOffsetInput" placeholder="分钟数" />
-          <text class="offset-unit">分钟</text>
+          <input class="offset-input" type="number" :value="String(offsetMinutes)" @input="onOffsetInput" placeholder="鍒嗛挓鏁? />
+          <text class="offset-unit">鍒嗛挓</text>
         </view>
         <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">浠诲姟姝e父瀹屾垚鏃讹紝鍙戦€佽〃鎵紦鍔辨秷鎭?/text>
+        <view class="channel-list" v-if="completionPraiseEnabled === 1" style="margin-top:20rpx">
+          <view class="channel-item" v-for="(ch, pIndex) in channels" :key="'p-' + pIndex"
+            :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>
+        <text class="tip-text">馃挕 绔欏唴娑堟伅鎻愰啋濮嬬粓寮€鍚紝浣滀负鍏滃簳閫氶亾涓嶉殢浠ヤ笂璁剧疆鍏抽棴</text>
       </view>
 
-      <!-- 保存按钮 -->
+      <!-- 淇濆瓨鎸夐挳 -->
       <view class="save-area">
         <view class="save-btn" @tap="saveConfig">
-          <text>{{ saving ? '保存中...' : '保存配置' }}</text>
+          <text>{{ saving ? '淇濆瓨涓?..' : '淇濆瓨閰嶇疆' }}</text>
         </view>
       </view>
     </scroll-view>
@@ -85,28 +110,30 @@ export default {
       selectedChannels: [],
       timingMode: 'after_timeout',
       offsetMinutes: 60,
+      completionPraiseEnabled: 0,
+      selectedPraiseChannels: ['wechat', 'in_app'],
       _loaded: false
     }
   },
   computed: {
     offsetLabel: function() {
-      return this.timingMode === 'before_deadline' ? '提前' : '超时后'
+      return this.timingMode === 'before_deadline' ? '鎻愬墠' : '瓒呮椂鍚?
     },
     offsetHint: function() {
       if (!this.offsetMinutes || this.offsetMinutes < 1 || this.offsetMinutes > 1440) {
-        return '请输入 1-1440 之间的分钟数'
+        return '璇疯緭鍏?1-1440 涔嬮棿鐨勫垎閽熸暟'
       }
       if (this.timingMode === 'before_deadline') {
-        return '将在任务截止前 ' + this.offsetMinutes + ' 分钟提醒'
+        return '灏嗗湪浠诲姟鎴鍓?' + this.offsetMinutes + ' 鍒嗛挓鎻愰啋'
       }
-      return '将在任务超时后 ' + this.offsetMinutes + ' 分钟提醒'
+      return '灏嗗湪浠诲姟瓒呮椂鍚?' + this.offsetMinutes + ' 鍒嗛挓鎻愰啋'
     },
     channelTip: function() {
       var selected = this.selectedChannels || []
       if (selected.length === 0) {
-        return '请至少选择一个提醒渠道'
+        return '璇疯嚦灏戦€夋嫨涓€涓彁閱掓笭閬?
       }
-      return '已选择 ' + selected.length + ' 个提醒渠道'
+      return '宸查€夋嫨 ' + selected.length + ' 涓彁閱掓笭閬?
     }
   },
   onLoad: function() {
@@ -124,19 +151,27 @@ 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
-      uni.showToast({ title: '加载失败,请重试', icon: 'none' })
+      uni.showToast({ title: '鍔犺浇澶辫触锛岃閲嶈瘯', icon: 'none' })
     })
   },
   methods: {
     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
     },
@@ -157,12 +201,12 @@ export default {
     saveConfig: function() {
       var self = this
       if (!this.selectedChannels || this.selectedChannels.length === 0) {
-        uni.showToast({ title: '请至少选择一个提醒渠道', icon: 'none' })
+        uni.showToast({ title: '璇疯嚦灏戦€夋嫨涓€涓彁閱掓笭閬?, icon: 'none' })
         return
       }
       var offset = this.offsetMinutes || 0
       if (offset < 1 || offset > 1440) {
-        uni.showToast({ title: '偏移分钟需在 1-1440 之间', icon: 'none' })
+        uni.showToast({ title: '鍋忕Щ鍒嗛挓闇€鍦?1-1440 涔嬮棿', icon: 'none' })
         return
       }
       if (this.saving) return
@@ -171,227 +215,24 @@ 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) {
-          uni.showToast({ title: '保存成功', icon: 'success' })
+          uni.showToast({ title: '淇濆瓨鎴愬姛', icon: 'success' })
           setTimeout(function() {
             uni.navigateBack()
           }, 1000)
         } else {
-          uni.showToast({ title: (res && res.message) || '保存失败', icon: 'none' })
+          uni.showToast({ title: (res && res.message) || '淇濆瓨澶辫触', icon: 'none' })
         }
       }).catch(function(err) {
         self.saving = false
-        uni.showToast({ title: (err && err.message) || '保存失败', icon: 'none' })
+        uni.showToast({ title: (err && err.message) || '淇濆瓨澶辫触', icon: 'none' })
       })
     }
   }
 }
 </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>

+ 19 - 6
cfc-frontend/pages/vendor/product-edit/product-edit.vue

@@ -90,15 +90,16 @@ export default {
   },
   methods: {
     onTypeChange(e) {
-      this.typeIndex = e.detail.value
-      this.form.productType = this.typeOptions[e.detail.value].value
+      var idx = Number(e.detail.value)
+      this.typeIndex = idx
+      this.form.productType = this.typeOptions[idx].value
     },
     loadProduct(id) {
       uni.showLoading({ title: '加载中...' })
-      productDetail({ id: Number(id) }).then(res => {
+      productDetail({ id: Number(id) }).then(function(res) {
         uni.hideLoading()
         if (res.code === 200 && res.data) {
-          const p = res.data
+          var p = res.data
           this.form.name = p.name || ''
           this.form.intro = p.intro || ''
           this.form.description = p.description || ''
@@ -107,12 +108,13 @@ export default {
           this.form.stock = p.stock || 0
           this.form.productType = p.productType || 'physical'
           // sync picker index
-          var idx = this.typeOptions.findIndex(function(t) { return t.value === this.form.productType }, this)
+          var self = this
+          var idx = this.typeOptions.findIndex(function(t) { return t.value === self.form.productType })
           if (idx >= 0) this.typeIndex = idx
         } else {
           uni.showToast({ title: res.message || '加载失败', icon: 'none' })
         }
-      }).catch(function() {
+      }.bind(this)).catch(function() {
         uni.hideLoading()
         uni.showToast({ title: '加载失败', icon: 'none' })
       })
@@ -209,6 +211,17 @@ export default {
   background: #fafafa;
   resize: none;
 }
+.form-hint {
+  display: block;
+  font-size: 22rpx;
+  color: #999;
+  margin-top: 8rpx;
+  line-height: 1.5;
+}
+.form-item switch {
+  float: right;
+  margin-top: 6rpx;
+}
 .form-picker {
   height: 80rpx;
   padding: 0 24rpx;

+ 15 - 5
cfc-frontend/pages/wealth-cf/index.vue

@@ -1,5 +1,7 @@
 <template>
   <view class="container">
+    <!-- 登录浮层(全局组件,页面内弹出底部登录) -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- 未登录 -->
     <template v-if="!isLoggedIn">
       <view class="empty-state">
@@ -129,7 +131,15 @@ export default {
       this.isLoggedIn = !!token
     },
     goLogin() {
-      uni.navigateTo({ url: '/pages/login/login' })
+      this.openLoginSheet()
+    },
+    // 登录浮层成功回调:刷新登录态、余额与流水
+    onLoginSuccess() {
+      this.checkLogin()
+      if (this.isLoggedIn) {
+        this.loadBalance()
+        this.loadLogs()
+      }
     },
     async loadBalance() {
       try {
@@ -266,7 +276,7 @@ export default {
   width: 280rpx;
   height: 80rpx;
   line-height: 80rpx;
-  background: linear-gradient(135deg, #667eea, #764ba2);
+  background: linear-gradient(135deg, #F97316, #FB923C);
   color: #fff;
   border-radius: 40rpx;
   font-size: 28rpx;
@@ -275,7 +285,7 @@ export default {
 .balance-card {
   margin: 30rpx;
   padding: 40rpx 30rpx;
-  background: linear-gradient(135deg, #667eea, #764ba2);
+  background: linear-gradient(135deg, #F97316, #FB923C);
   border-radius: 24rpx;
   color: #fff;
 }
@@ -353,7 +363,7 @@ export default {
 }
 .section-more {
   font-size: 24rpx;
-  color: #667eea;
+  color: #F97316;
 }
 .log-list {
   background: #fff;
@@ -470,7 +480,7 @@ export default {
 }
 .coupon-price {
   font-size: 26rpx;
-  color: #667eea;
+  color: #F97316;
   font-weight: 600;
   white-space: nowrap;
 }

+ 34 - 43
cfc-frontend/pages/wealth/index.vue

@@ -1,8 +1,13 @@
 <template>
   <view class="container">
+    <!-- 通用登录浮层 -->
+    <login-sheet ref="loginSheet" @success="onSheetLoginSuccess" />
     <!-- TabTransition overlay -->
     <tab-transition v-if="showTabTransition" dimCode="wealth" ref="tabTransition" @close="showTabTransition = false" />
 
+    <!-- 品牌头部 -->
+    <PageBanner theme="wealth" tagline="家庭财富能量 · 利他创富" quote="利他创富 · 富足人生" />
+
     <!-- 未登录时显示精简内容 -->
     <template v-if="!isLoggedIn">
       <!-- 品牌定位语 -->
@@ -16,16 +21,18 @@
         <text class="guidance-text">{{ monthlyGuidance }}</text>
       </view>
 
-      <!-- 登录引导 -->
-      <view class="login-guide-section">
-        <LoginGuideCard
-          title="解锁财富能量与推广收益"
-          :items="[
-            '查看家庭财富能量仪表盘与推广收益',
-            '管理邀请返利,开通会员享专属折扣',
-            '浏览商城精选商品,一键加入购物车'
-          ]" />
-      </view>
+      <!-- 游客价值卡(统一组件:维度色 CTA + 价值预览) -->
+      <DimensionVisitorCard
+        dimCode="wealth"
+        card-title="富维度 · 利他创富"
+        :benefits="[
+          '家庭财富能量仪表盘与推广收益',
+          '邀请返利与会员专属折扣',
+          '商城精选好物一键下单'
+        ]"
+        cta-text="登录解锁财富能量"
+        secondary-text="逛逛商城"
+        secondary-url="/pages/shop/index/index" />
 
       <!-- 商城精选预览(无需登录) -->
       <view class="section">
@@ -261,6 +268,7 @@ import BodyWealthAlert from '@/pages/wealth/components/BodyWealthAlert.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionIntroCard from '../../components/DimensionIntroCard.vue'
+import DimensionVisitorCard from '../../components/DimensionVisitorCard.vue'
 import DimensionSubDims from '../../components/DimensionSubDims.vue'
 import {
   getChildren,
@@ -281,7 +289,7 @@ import nav from '../../utils/nav.js'
 import config from '../../config.js'
 
 export default {
-  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, FamilyEnergyBar, DimensionIntroCard, DimensionSubDims },
+  components: { TabTransition, WealthEnergyRing, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, FamilyEnergyBar, DimensionIntroCard, DimensionSubDims, DimensionVisitorCard },
   data() {
     return {
       showTabTransition: true,
@@ -506,7 +514,19 @@ export default {
       return map[status] || status || '未知'
     },
     goLogin() {
-      uni.navigateTo({ url: '/pages/login/login' })
+      this.openLoginSheet()
+    },
+    // 登录成功:刷新登录态数据(镜像 onShow 已登录分支)
+    onLoginSuccess() {
+      this.isLoggedIn = true
+      this.loadChildren()
+      this.loadWealthDetail()
+      this.loadWealthData()
+      this.loadChildData()
+      this.loadFamilyMembersVisible()
+      this.loadInsuranceList()
+      this.loadSandboxData()
+      this.loadEnergyData()
     },
     _watchPageReady() {
       var self = this
@@ -1035,35 +1055,6 @@ export default {
 }
 
 /* 未登录 */
-.login-guide-section {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  padding: 60rpx 40rpx;
-  margin: 30rpx;
-  background: #fff;
-  border-radius: 20rpx;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-.login-guide-text {
-  font-size: 26rpx;
-  color: #999;
-  text-align: center;
-  margin-bottom: 30rpx;
-  line-height: 1.6;
-}
-.login-guide-section .login-btn {
-  width: 60%;
-  height: 80rpx;
-  line-height: 80rpx;
-  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
-  color: #fff;
-  font-size: 30rpx;
-  font-weight: bold;
-  border-radius: 40rpx;
-  text-align: center;
-  border: none;
-}
 .login-prompt .login-btn::after {
   border: none;
 }
@@ -1074,7 +1065,7 @@ export default {
   padding: 20rpx 28rpx;
   background: linear-gradient(135deg, #FFFBEB, rgba(245,158,11,0.12));
   border-radius: 16rpx;
-  border-left: 4rpx solid #F59E0B;
+  border-left: 8rpx solid #F59E0B;
 }
 .brand-strap-text {
   font-size: 26rpx;
@@ -1305,7 +1296,7 @@ export default {
   padding: 16rpx 20rpx;
   background: linear-gradient(135deg, #FFF7ED, #FFF3E8);
   border-radius: 12rpx;
-  border-left: 4rpx solid #F59E0B;
+  border-left: 8rpx solid #F59E0B;
   margin-top: 16rpx;
 }
 .portrait-tip-text {

Деякі файли не було показано, через те що забагато файлів було змінено