Parcourir la source

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

Sisyphus Agent il y a 1 mois
Parent
commit
22932d4cb2
100 fichiers modifiés avec 3320 ajouts et 1493 suppressions
  1. 192 0
      book/design/插图规划清单.md
  2. 99 0
      book/design/知识产权查证-24730章鱼图珍珠图.md
  3. 149 1
      book/成书稿.txt
  4. 2 1
      cfc-backend/config/application-prod.yml
  5. 2 0
      cfc-backend/config/application.yml
  6. 55 52
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  7. 91 0
      cfc-backend/src/main/java/com/etotem/cfc/config/ErrorLoggingFilter.java
  8. 10 0
      cfc-backend/src/main/java/com/etotem/cfc/config/GlobalExceptionHandler.java
  9. 31 2
      cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java
  10. 8 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java
  11. 14 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java
  12. 22 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/MediaController.java
  13. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java
  14. 26 5
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityRegistrationController.java
  15. 43 3
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java
  16. 18 4
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/DimensionWeightController.java
  17. 54 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/VirtualGoodsConfigAdminController.java
  18. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PaymentController.java
  19. 18 2
      cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java
  20. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java
  21. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/PackageOrder.java
  22. 44 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/VirtualGoodsConfig.java
  23. 9 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/VirtualGoodsConfigMapper.java
  24. 48 6
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java
  25. 39 20
      cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java
  26. 50 0
      cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
  27. 49 0
      cfc-backend/src/main/java/com/etotem/cfc/service/VirtualGoodsConfigService.java
  28. 147 2
      cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java
  29. 3 0
      cfc-backend/src/main/java/com/etotem/cfc/service/api/WechatServiceInterface.java
  30. 2 0
      cfc-backend/src/main/resources/application.yml
  31. 22 3
      cfc-backend/src/main/resources/schema.sql
  32. 11 11
      cfc-backend/src/test/java/com/etotem/cfc/controller/ActivityControllerTest.java
  33. 135 531
      cfc-frontend/components/ArticleBookshelf.vue
  34. 34 10
      cfc-frontend/components/ContentSharePoster.vue
  35. 6 1
      cfc-frontend/components/DimensionActivities.vue
  36. 0 202
      cfc-frontend/components/DimensionProductList.vue
  37. 122 18
      cfc-frontend/components/DimensionProducts.vue
  38. 7 2
      cfc-frontend/components/RecommendedFeed.vue
  39. 0 180
      cfc-frontend/components/RecommendedSection.vue
  40. 0 1
      cfc-frontend/config.js
  41. 0 122
      cfc-frontend/mixins/recommend-mixin.js
  42. 6 0
      cfc-frontend/pages.json
  43. 7 18
      cfc-frontend/pages/action/index.vue
  44. 18 21
      cfc-frontend/pages/activity/activity-detail/activity-detail.vue
  45. 35 0
      cfc-frontend/pages/activity/create.vue
  46. 157 79
      cfc-frontend/pages/activity/index.vue
  47. 390 0
      cfc-frontend/pages/activity/registrations.vue
  48. 5 1
      cfc-frontend/pages/ai/chat.vue
  49. 11 6
      cfc-frontend/pages/article-center/article-detail.vue
  50. 5 1
      cfc-frontend/pages/article-center/index.vue
  51. 5 1
      cfc-frontend/pages/article-center/my-posts.vue
  52. 5 1
      cfc-frontend/pages/body-detail/dimension-detail.vue
  53. 13 17
      cfc-frontend/pages/body/index.vue
  54. 5 1
      cfc-frontend/pages/dan-assessment/report-upload.vue
  55. 7 6
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  56. 5 1
      cfc-frontend/pages/discover/index.vue
  57. 6 2
      cfc-frontend/pages/guide/activities/detail.vue
  58. 5 1
      cfc-frontend/pages/guide/activities/index.vue
  59. 5 1
      cfc-frontend/pages/guide/packages/share.vue
  60. 5 1
      cfc-frontend/pages/health/report-upload.vue
  61. 7 18
      cfc-frontend/pages/index-home/index.vue
  62. 5 1
      cfc-frontend/pages/meal/recipe-detail.vue
  63. 17 3
      cfc-frontend/pages/membership/upgrade.vue
  64. 6 6
      cfc-frontend/pages/mind-detail/articles.vue
  65. 6 6
      cfc-frontend/pages/mind-extra/articles.vue
  66. 25 18
      cfc-frontend/pages/mind/index.vue
  67. 6 2
      cfc-frontend/pages/parent/market/detail.vue
  68. 5 1
      cfc-frontend/pages/parent/market/index.vue
  69. 5 1
      cfc-frontend/pages/points/mall.vue
  70. 5 2
      cfc-frontend/pages/promotion/material.vue
  71. 4 1
      cfc-frontend/pages/shop/cart/cart.vue
  72. 4 1
      cfc-frontend/pages/shop/checkout/checkout.vue
  73. 6 2
      cfc-frontend/pages/shop/index/index.vue
  74. 5 1
      cfc-frontend/pages/shop/order-detail/order-detail.vue
  75. 5 1
      cfc-frontend/pages/shop/order-list/order-list.vue
  76. 2 2
      cfc-frontend/pages/user-edit/user-edit.vue
  77. 5 1
      cfc-frontend/pages/vendor/products/products.vue
  78. 32 21
      cfc-frontend/pages/wealth/index.vue
  79. 13 17
      cfc-frontend/pages/wisdom/index.vue
  80. 4 0
      cfc-frontend/utils/api.js
  81. 1 1
      cfc-web/.last_build_commit
  82. 2 2
      cfc-web/package-lock.json
  83. 1 1
      cfc-web/package.json
  84. 307 0
      cfc-web/public/CHANGELOG-v1.0.md
  85. 308 1
      cfc-web/public/CHANGELOG.md
  86. 3 2
      cfc-web/src/styles/admin-tokens.css
  87. 9 6
      cfc-web/src/views/admin/ActivityEdit.vue
  88. 23 22
      cfc-web/src/views/admin/ArticleEdit.vue
  89. 2 2
      cfc-web/src/views/admin/ArticleManage.vue
  90. 6 2
      cfc-web/src/views/admin/ProductEdit.vue
  91. 1 1
      web/articles/alzheimer.html
  92. 1 1
      web/articles/cardio-cerebro.html
  93. 150 1
      web/articles/enagic-turmeric.html
  94. 1 1
      web/articles/geo-faq-1-allergy.html
  95. 1 1
      web/articles/gut-microbiome-immune.html
  96. BIN
      web/articles/hrw-individual-variability-2026.png
  97. 1 1
      web/articles/leaky-gut.html
  98. 1 1
      web/articles/memory-gut.html
  99. 1 1
      web/articles/metabolic-syndrome.html
  100. 1 1
      web/articles/pancreatitis.html

+ 192 - 0
book/design/插图规划清单.md

@@ -0,0 +1,192 @@
+# 《家的算法》插图规划清单
+
+> 生成日期:2026-08-02
+> 用途:本书全部插图的设计交付清单。全书共 **33 张**插图(16 张章首题图 + 17 张工具示意图)。
+> 对应书稿:`book/成书稿.txt` 中以 `【插图占位:IMG-XX-XX|...】` 标注的位置。
+> 版权红线:**全部插图必须原创绘制**,不得使用任何网络图片/素材(详见 `知识产权查证-24730章鱼图珍珠图.md`)。
+
+---
+
+## 一、通用风格规范(全书统一)
+
+| 项目 | 规范 |
+|------|------|
+| **整体风格** | 治愈系手绘扁平插画:圆润造型、暖色调、留白充足,家庭生活场景感;避免写实、避免科技冷感 |
+| **人物** | 圆脸简笔画风一家人(小付/小茜/小艾/小宝),形象全书统一,可复用组件化 |
+| **色系** | 全书暖色底(米白/奶油色),五维维度色点缀:身橙 `#FF8C42`、智蓝 `#6366F1`、富金 `#F59E0B`、行绿 `#10B981`、心粉 `#FF6B9D` |
+| **文字** | 图中文字用标准中文字体(思源黑体/阿里巴巴普惠体),图注统一为一行小字 |
+| **尺寸建议** | 章首题图:满版宽(横版,如 16:9 或 4:3);工具示意图:通栏或半栏(1:1 或 4:3),保证图中文字可读 |
+| **图注格式** | 统一放图下方,居中,浅灰小字 |
+
+---
+
+## 二、插图总览表
+
+| 图号 | 类型 | 位置 | 图名 |
+|------|------|------|------|
+| IMG-00-01 | 章首题图 | 序章开头 | 一个普通家庭的周末 |
+| IMG-01-00 | 章首题图 | 第1章开头 | 深夜的焦虑 |
+| IMG-02-00 | 章首题图 | 第2章开头 | 幸福三支柱 |
+| IMG-02-01 | 工具示意图 | 第2章·五行相生段落前 | 五行相生相克环形图 |
+| IMG-03-00 | 章首题图 | 第3章开头 | 守住基本盘 |
+| IMG-03-01 | 工具示意图 | 第3章「一杯水的启发」节前 | 最小输入撬动全家健康 |
+| IMG-04-00 | 章首题图 | 第4章开头 | 成长的种子 |
+| IMG-04-01 | 工具示意图 | 第4章·固定/成长型思维段落前 | 固定型思维 vs 成长型思维 |
+| IMG-04-02 | 工具示意图 | 第4章·认知画像段落前 | 认知画像雷达图示例 |
+| IMG-05-00 | 章首题图 | 第5章开头 | 藏宝图与第二曲线 |
+| IMG-05-01 | 工具示意图 | 第5章「别画愿景图,画藏宝图」节前 | 藏宝图示例(三圆交叠) |
+| IMG-05-02 | 工具示意图 | 第5章·财务体检段落前 | 家庭财务体检四数字卡片 |
+| IMG-06-00 | 章首题图 | 第6章开头 | 以关系滋养幸福 |
+| IMG-06-01 | 工具示意图 | 第6章「第三空间」节前 | 第三空间示意图 |
+| IMG-06-02 | 工具示意图 | 第6章·关系账户段落前 | 关系能量账户示意图 |
+| IMG-06-03 | 工具示意图 | 第6章「两幅图:章鱼图与珍珠图」节前 | 章鱼图示例 |
+| IMG-06-04 | 工具示意图 | 第6章·珍珠图定义处 | 珍珠图示例 |
+| IMG-07-00 | 章首题图 | 第7章开头 | 家的港湾 |
+| IMG-07-01 | 工具示意图 | 第7章「情绪停机坪」节前 | 情绪停机坪示意图 |
+| IMG-07-02 | 工具示意图 | 第7章「闻花香+吹蜡烛法」节前 | 闻花香+吹蜡烛法 |
+| IMG-07-03 | 工具示意图 | 第7章「4-7-8呼吸法」节前 | 4-7-8呼吸法节奏图 |
+| IMG-08-00 | 章首题图 | 第8章开头 | 轻轻一推 |
+| IMG-08-01 | 工具示意图 | 第8章·五维最小输入清单处 | 五维最小输入清单卡片 |
+| IMG-09-00 | 章首题图 | 第9章开头 | 习惯的循环 |
+| IMG-09-01 | 工具示意图 | 第9章「习惯回路四要素」节前 | 习惯回路四要素图 |
+| IMG-10-00 | 章首题图 | 第10章开头 | 看见改变 |
+| IMG-10-01 | 工具示意图 | 第10章「反馈闭环」节前 | 反馈闭环示意图 |
+| IMG-10-02 | 工具示意图 | 第10章「五维能量地图」节前 | 五维能量地图示意图 |
+| IMG-11-00 | 章首题图 | 第11章开头 | 版本迭代 |
+| IMG-12-00 | 章首题图 | 第12章开头 | 打开端口 |
+| IMG-13-00 | 章首题图 | 第13章开头 | 幸福网络 |
+| IMG-14-00 | 章首题图 | 终章开头 | 幸福全景图 |
+| IMG-15-00 | 章首题图 | 附录开头 | 工具包总览 |
+
+---
+
+## 三、逐图说明
+
+### 序章
+**IMG-00-01 一个普通家庭的周末**
+清晨阳光洒进客厅,一家四口围坐吃早餐的温馨全景;窗外远景自然融入五维元素——晨跑的人(身)、读书的孩子(智)、记账的家长(富)、通电话的爸爸(行)、给老人倒水的妈妈(心),不做文字标注,全靠画面语言。暖色调治愈手绘,无文字。呼应终章 IMG-14-00。
+
+### 第1章
+**IMG-01-00 深夜的焦虑**
+深夜,一位家长独自坐在床边,手机屏幕的冷光照亮半张脸,屏幕上隐约是体检报告、成绩单、账单的剪影;窗外静谧的深蓝城市夜景。低饱和蓝灰冷色调,与序章暖色形成对比,为全书"先看见"主题铺垫。无文字。
+
+### 第2章
+**IMG-02-00 幸福三支柱**
+一艘家庭小船稳稳停在水面,由三根立柱支撑:饭碗与钱币(衣食无忧)、奖杯与握手(受人尊敬)、书本与牵手(子女传承)。柱身标注"衣食无忧""受人尊敬""子女传承"。结构示意+插画,暖色调。
+
+**IMG-02-01 五行相生相克环形图**
+五个圆按相生顺序环形排列(身→智→富→行→心→身),环内写维度名+关键词(身-主动健康/智-赋能未来/富-利他创富/行-人际和谐/心-大爱相随);外圈实线箭头=相生(身生智、智生富、富生行、行生心、心生身),虚线=相克(身克富、智克行、富克心、行克身、心克智)。五环用五维色系。全书核心图,需最清晰、可放大印刷。
+
+### 第3章
+**IMG-03-00 守住基本盘**
+一棵扎根大地的树,树根深扎土壤("基本盘"意象),树干上站着安然的一家人;画面底部有体检报告与一杯水的小元素。暖橙色调。
+
+**IMG-03-01 最小输入撬动全家健康**
+杠杆式构图:左侧一个小人只做一件事——端起一杯水;右侧撬起整个家庭系统:体检报告、均衡饮食、规律作息、饭后散步。图注一行:"一杯水,就是改变全家健康的起点。"
+
+### 第4章
+**IMG-04-00 成长的种子**
+一颗种子破土而出长成幼苗,叶片上托着打开的书本;背景是孩子举手提问、家长俯身倾听、全家阅读的剪影。蓝金色系。
+
+**IMG-04-01 固定型思维 vs 成长型思维**
+左右对照:左(冷灰调)"固定型思维":封闭圆环内写"我不会""我不行""算了";右(暖色调)"成长型思维":破土幼苗叶片写"我还没学会""我要试试""再来一次"。底部一行:"把'你真聪明'改成'你刚才用了什么方法'。"
+
+**IMG-04-02 认知画像雷达图示例**
+六边形雷达图,标注认知维度(注意力、加工速度、记忆力、推理能力、空间能力等);两条线:孩子能力线 + 家长认为的线,两线明显不同。图注:"坐下来,看完对方的报告,问一句——'原来你是这样想的。'"蓝金色系,数据可视化。
+
+### 第5章
+**IMG-05-00 藏宝图与第二曲线**
+微微卷起的手绘藏宝图卷轴铺在桌上,图上标记"我能为别人提供什么"的宝藏点(技能、经验、人脉、口碑);卷轴一端延伸出向上扬起的"第二收入曲线"。暖金调。
+
+**IMG-05-01 藏宝图示例(三圆交叠)**
+三个相交圆:左"我会做什么"(技能)、右"我拥有什么"(经验/人脉)、下"别人需要什么"(需求);三圆交叠正中一颗星+"利他创富"。图注:"别画愿景图,画藏宝图——从'我能为别人提供什么'开始。"
+
+**IMG-05-02 家庭财务体检四数字卡片**
+四张并排卡片,每张一个大数字+解释:①应急资金覆盖月数 ②储蓄率 ③负债收入比 ④保险覆盖;下方铅笔勾选"完成"样式。图注:"十分钟,算四个数,看清家庭底线。"金色系,数字醒目。
+
+### 第6章
+**IMG-06-00 以关系滋养幸福**
+一棵大树,枝干间一家三口与朋友、邻居围坐,人与人之间是温柔连接线(像血管输送养分);远处同事与老友形象。绿色调。
+
+**IMG-06-01 第三空间示意图**
+三个圆:大圆"家庭"、大圆"工作/育儿",两圆之外独立小圆"第三空间"(画咖啡馆/河边长椅),两圆之间一扇小门通往第三空间。图注:"每周30分钟'不谈家事'的对话,比任何技巧都有效。"
+
+**IMG-06-02 关系能量账户示意图**
+"情感储蓄罐"左右分栏:左侧"存款"(拥抱、一句辛苦了、一杯水、一条消息)落入罐中;右侧"取款"(指责、冷战、敷衍)拿出;罐身5:1比例刻度条,存款端更高。图注:"小额存款,比偶尔一次大额存款重要得多。"
+
+**IMG-06-03 章鱼图示例**
+纸中央一个圆(写"我"),伸出4-6条弯曲章鱼触手,每条写一种关系(同事/朋友/亲戚/邻居/老师/旧同学),触手末端小爱心。图注:"你不是一个人在硬撑——你身后一直有一群托举过你的人。"手绘趣味,圆润可爱。
+
+**IMG-06-04 珍珠图示例**
+一条弧线项链串起颗颗珍珠,每颗写一种资源:认识的一个人、掌握的一条信息、去过的一个地方、学过的一项技能;两端一双手轻轻托住。图注:"你手里的牌,比你以为的多。"与章鱼图风格统一。
+
+### 第7章
+**IMG-07-00 家的港湾**
+夜晚暴风雨中,小房子的窗户透出温暖橙粉光,像海边灯塔;屋内有家人围坐身影,窗外风雨交加屋里安宁。暖粉调,光影对比明显。
+
+**IMG-07-01 情绪停机坪示意图**
+情绪曲线从平静急速爬升到爆发点,爆发点前一个醒目"暂停键"(圆形按钮)横在曲线上;下方三步小图:①停下来 ②深呼吸 ③说出感受。图注:"在爆发前按下暂停键。"
+
+**IMG-07-02 闻花香+吹蜡烛法**
+左右两幅对照:左图人面前一朵茉莉花,箭头向上"深深吸气";右图同一人面前一支点燃蜡烛,箭头向前"缓缓呼气吹灭"。两图之间箭头连接。图注:"最简单的情绪开关。"两格漫画式,动作夸张可爱。
+
+**IMG-07-03 4-7-8呼吸法节奏图**
+平滑时间轴波形,三段色块:吸气4秒(短弧上升)、屏息7秒(平直段)、呼气8秒(长弧下降);下方标注"吸气 4秒""屏息 7秒""呼气 8秒",顶部小字"4-7-8"。图注:"给心灵的深度按摩。"
+
+### 第8章
+**IMG-08-00 轻轻一推**
+一列多米诺骨牌,第一块最小最轻,手指正要轻推;骨牌由小到大依次倒下,最后一块倒向一双跑鞋。橙绿色调,动态感。
+
+**IMG-08-01 五维最小输入清单卡片**
+五张竖排卡片,每张维度色+小图标+一行字:身(橙)喝一杯水、智(蓝)问一个开放题、富(金)记一笔账、行(绿)给家人发一条消息、心(粉)深呼吸一次。底部统一小字:"小到不可能失败,小到不需要意志力。"
+
+### 第9章
+**IMG-09-00 习惯的循环**
+旋转的环形箭头齿轮,中心是晨跑的人;环形四个节点小图标:闹钟(触发)、跑鞋(动作)、多巴胺火花(奖励)、发光灯泡(信念)。绿色调,动感。
+
+**IMG-09-01 习惯回路四要素图**
+环形图四节点:触发(床边跑鞋/闹钟)→ 动作(穿上跑鞋出门)→ 奖励(多巴胺火花+成就感)→ 信念(发光灯泡内写"跑步对我有好处"),环形箭头闭环,中心标注"习惯回路"。图注:"循环执行不是加大剂量,是保持剂量不变。"
+
+### 第10章
+**IMG-10-00 看见改变**
+一面立镜,镜中映出不断上升的折线图(从低谷爬升);一只手正指着上扬的转折点,镜框边角贴一张月度打分便签。蓝绿色调,数据感与温度并存。
+
+**IMG-10-01 反馈闭环示意图**
+四个圆角方块环形排列,箭头闭环:行为(行动的人)→ 数据(记录本/图表)→ 感知(眼睛/心形)→ 强化(向上箭头)→ 回到行为。图注:"大多数人缺的不是行为,是数据。"
+
+**IMG-10-02 五维能量地图示意图**
+月度网格纸上五条彩色折线:身橙、智蓝、富金、行绿、心粉,每月一个圆点连成线,整体缓慢上升;纸面边缘一支笔。图注:"每月打分,连点成线,季度看趋势——不看单次,看方向。"手账式,留白透气。与 IMG-14-00 呼应。
+
+### 第11章
+**IMG-11-00 版本迭代**
+家庭系统仪表盘/控制面板,角落三个递进版本标签:v1.0 → v1.1 → v1.2;面板功能是家里真实小事(晨间一杯水、复盘饭、记账、五维打分)的图标,每次升级只多一两个图标,不推倒重来。扁平插画,暖调。
+
+### 第12章
+**IMG-12-00 打开端口**
+一栋小房子打开好几扇窗和一扇门,温暖金色光点从窗口/门口(端口)流淌出去,流向街坊、朋友、社区;屋外有人伸手接过光点。暖金色调,光点流动感。
+
+### 第13章
+**IMG-13-00 幸福网络**
+中心一个发光的家庭节点(一家人的剪影),连接线向四周扩散到越来越多节点:朋友 → 朋友的朋友 → 陌生人;越往外节点越多、光越亮,n² 式扩散。每个节点一点暖光。暖色光效,扩散感。
+
+### 终章
+**IMG-14-00 幸福全景图**
+翻开一本封面印着猫的笔记本,展开页面上是完整五维能量地图——五条彩色折线(橙蓝金绿粉)在一年多网格上蜿蜒,汇聚成完整"幸福全景图";笔尖正停在最新一格。与序章 IMG-00-01、第10章 IMG-10-02 呼应,全书收束感。
+
+### 附录
+**IMG-15-00 工具包总览**
+一个打开的行李箱(工具箱),整齐摆着全书所有工具的迷你图标:一杯水、藏宝图、章鱼图、珍珠图、情绪停机坪暂停键、4-7-8波形、五维能量地图、财务体检卡片、跑鞋、家庭角色轮值表……每件工具旁一张"使用说明"便签。图注:"工具不在多,能用一个就先用一个。"细节丰富,读者可逐件辨认,呼应全书。
+
+---
+
+## 四、绘制优先级建议
+
+1. **第一优先(全书灵魂图)**:IMG-02-01 五行相生环形图、IMG-10-02 五维能量地图、IMG-06-03 章鱼图、IMG-06-04 珍珠图——四张是方法论核心,也是未来品牌/社群可复用的资产
+2. **第二优先(章首题图)**:序章、第1章、第8章、终章——决定读者第一印象
+3. **第三优先(其余工具图)**:按章节顺序推进
+
+## 五、复用与衍生建议
+
+- 章鱼图、珍珠图、五行相生环、五维能量地图四张图可拆解为**品牌视觉资产**,用于公众号、课程、App 图标
+- 五维色系已与公司平台(小程序/官网)一致,插图可直接沿用
+- 每张工具图建议同时输出"线稿版"(可印刷/涂色)与"彩色版"

+ 99 - 0
book/design/知识产权查证-24730章鱼图珍珠图.md

@@ -0,0 +1,99 @@
+# 知识产权查证档案:24730 / 章鱼图 / 珍珠图
+
+> 查证日期:2026-08-02
+> 查证范围:《家的算法》书稿中三个核心概念名的版权/商标/撞名风险
+> 结论:**三个概念名均可安全使用,无需改名、无需授权**;唯「章鱼图」存在一个需要知情的行业撞名点
+
+---
+
+## 一、结论速览
+
+| 名称 | 书中含义 | 版权风险 | 商标风险 | 撞名风险 | 结论 |
+|------|---------|:-------:|:-------:|:-------:|------|
+| **24730法则** | 新关系生长节奏:24小时联系、7天见面、30天找TA办一件小事 | ✅ 无 | ✅ 无 | ✅ 无 | 安全 |
+| **章鱼图** | 画一个圆(自己)+ 触手写上帮助过自己的人,盘点"我有人可求" | ✅ 无 | ✅ 无 | ⚠️ 与 IATF 16949 汽车行业"章鱼图"撞名 | 安全(需知情) |
+| **珍珠图** | 把可调用的外部资源串成珍珠项链,盘点"我有什么" | ✅ 无 | ✅ 无 | ✅ 无 | 安全 |
+
+## 二、版权层面分析
+
+### 法律原则
+
+**著作权保护"表达",不保护"思想/方法"。** 依据《著作权法》第三条、第二十四至二十六条(合理使用与保护范围的边界),以及 TRIPS 协议第 9 条第 2 款:"著作权保护应延及表达,而不延及思想、程序、操作方法或数学概念本身"。
+
+因此:
+- "24730法则""章鱼图""珍珠图"作为**方法论概念**不受版权保护——与"二八法则""木桶效应""番茄工作法"同理,任何作者都可以独立使用这些名字
+- 受版权保护的是:
+  - 书中的**具体文字表述**(我们的原创文字,归作者/出版方所有)
+  - 书中的**配图插画**(必须是原创绘制)
+
+### 配图红线(必须遵守)
+
+⚠️ 不可直接使用网络上的章鱼/珍珠/人物类**美术作品**做插图。查证中发现跨境维权案例:
+
+| 作品 | 注册号 | 说明 |
+|------|--------|------|
+| 章鱼毛绒玩具 | VA 2-305-257 | 有版权的具体作品 |
+| 章鱼马克杯 | VA 2-470-308 | 有版权的具体作品 |
+| 章鱼钓鱼画 | VA 2-479-655 | 有版权的具体作品 |
+| 珍珠相关珠宝摄影 | — | 属摄影作品,同样受保护 |
+
+**对策**:所有插图由我方原创绘制(画一个圆+几条触手、画一条珍珠项链即可,均为极简示意图,不存在侵权可能)。
+
+## 三、商标层面分析
+
+| 名称 | 检索结果 | 显著性分析 | 注册可能性 |
+|------|---------|-----------|-----------|
+| 24730 | 无任何注册商标记录 | 纯数字组合,属于《商标法》第十一条缺乏显著性的标志 | 极低(除非获得"第二含义",即长期使用形成知名度) |
+| 章鱼图 | 无注册商标记录 | 行业通用术语(IATF 16949 官方工具名)+ 通用描述词 | 极低 |
+| 珍珠图 | 无注册商标记录;"珍珠"相关商标全部在第14类珠宝商品 | 通用描述词 | 极低 |
+
+**书内使用**:作为书名/栏目名/方法名在图书正文中使用,不构成商标侵权(非商标性使用)。
+
+**未来若注册为品牌**(课程/App/栏目):
+- 需在出版前后分别做一次**正式商标检索**(中国商标网 sbj.cnipa.gov.cn,官网数据滞后仅供参考)
+- 建议尽早注册类别:第16类(图书/印刷品)、第41类(教育/培训/出版)、第9类(软件/App)
+- 三个名称均为"弱商标"(显著性低),若计划作为长期品牌,可考虑注册**图形商标**(如章鱼图/珍珠图的原创插画形象)而非文字商标——图形商标显著性强,注册成功率更高
+
+## 四、⚠️ 需要知情的撞名发现:「章鱼图」vs IATF 16949
+
+### 事实
+
+汽车行业质量管理体系标准 **IATF 16949:2016** 中包含一个官方标准工具,正式名称就叫 **「章鱼图」(Octopus Diagram)**:
+- 与「乌龟图」(Turtle Diagram)配套使用
+- 用途:识别"顾客导向过程"(COP)的输入/输出关系
+- 在制造业/汽车供应链行业非常知名
+
+### 法律判断
+
+- **不构成侵权**:通用术语,任何人均可使用;IATF 16949 标准本身是公开可用的行业标准
+- **无商标障碍**:标准术语不构成注册商标
+
+### 认知混淆风险(唯一真实风险)
+
+- 若目标读者中有制造业/质量管理背景者,看到"章鱼图"会先联想到汽车行业的那个工具
+- 本书是家庭教育/健康/关系主题,与汽车行业受众重叠极小
+- 书中"章鱼图"定义(画圆+触手盘点恩人)从字面即可理解,普通读者不会联想到质量管理
+
+### 决策选项
+
+| 选项 | 说明 | 建议 |
+|------|------|------|
+| **保留"章鱼图"**(推荐) | 生动形象、与字面含义一致,风险极低 | ✅ 默认 |
+| 改名"触手图" | 更直白,零联想;但损失生动性 | 可选 |
+| 书中加一句说明 | 如"此章鱼图非汽车行业质量管理工具" | 不必要,反而突兀 |
+
+## 五、建议清单
+
+1. **书内使用**:三个名字照常使用,无需改动、无需授权
+2. **配图**:全部原创绘制(圆圈+触手、圆圈+珍珠串均为极简示意图);不用网络图片
+3. **未来注册商标**(若作为课程/App/品牌):出版前后各做一次正式商标检索;优先注册图形商标
+4. **「章鱼图」改名**:非必须,由作者决策(详见上文"决策选项")
+5. **出版合同**:与出版社签订合同时,确保"著作权归作者/著作权人"条款明确(涉及衍生改编权)
+
+## 六、查证方法与局限性
+
+- 查证方式:多轮网络检索(商标注册记录、理论名称、行业标准术语比对)
+- **局限性**:
+  - 网络公开检索 ≠ 官方商标数据库全量检索;正式注册前须以中国商标网为准
+  - 未做境外(美国 USPTO / 欧盟 EUIPO)商标检索——本书暂无境外出版计划,风险可接受
+  - 法律分析为一般性参考,不构成正式法律意见;重大商业决策前建议咨询知识产权律师

+ 149 - 1
book/成书稿.txt

@@ -3,6 +3,13 @@
 ====================
 # 序章:一个普通家庭的周末
 
+【插图占位:IMG-00-01|序章·章首题图|
+图名:一个普通家庭的周末
+内容:清晨阳光洒进客厅,一家四口围坐吃早餐的温馨全景;窗外远景自然融入五维元素——晨跑的人(身)、读书的孩子(智)、记账的家长(富)、通电话的爸爸(行)、给老人倒水的妈妈(心),不做文字标注,全靠画面语言。
+风格:暖色调治愈系手绘扁平插画,圆润造型,低饱和暖色,无文字。】
+
+# 第1章:我们到底在为什么焦虑?
+
 > **这本书的一切,从一个周六开始。**
 
 ---
@@ -187,6 +194,11 @@
 ====================
 # 第1章:我们到底在为什么焦虑?
 
+【插图占位:IMG-01-00|第1章·章首题图|
+图名:深夜的焦虑
+内容:深夜,一位家长独自坐在床边,手机屏幕的冷光照亮半张脸,屏幕上隐约是体检报告、成绩单、账单的剪影;窗外是静谧的深蓝城市夜景。氛围克制压抑,与序章的暖色形成对比,为全书"先看见"主题铺垫。
+风格:低饱和蓝灰冷色调手绘插画,无文字。】
+
 > **如果你的家里也有一个"救火队长",这一章是为你写的。**
 
 ---
@@ -402,6 +414,11 @@
 ====================
 # 第2章:幸福三支柱——一个家庭的顶层设计
 
+【插图占位:IMG-02-00|第2章·章首题图|
+图名:幸福三支柱
+内容:一艘家庭小船稳稳停在水面,由三根立柱支撑,柱顶分别是:饭碗与钱币(衣食无忧)、奖杯与握手(受人尊敬)、书本与牵手(子女传承);柱身分别标注"衣食无忧""受人尊敬""子女传承"。
+风格:结构示意与插画结合,暖色调,文字清晰可读。】
+
 > **如果你家是一艘船,这三根柱子就是它的龙骨。**
 
 ---
@@ -584,6 +601,10 @@
 
 **先科学地看见,再简单地应对——这就是"主动健康"在五个维度上的同一个姿势。** 它不是身体的专利,而是五维通用的方法论。
 
+【插图占位:IMG-02-01|第2章·五行相生相克环形图|
+内容:五个圆形按五行相生顺序环形排列(身→智→富→行→心→身),每环内写维度名与关键词:身-主动健康、智-赋能未来、富-利他创富、行-人际和谐、心-大爱相随;外圈实线箭头标注相生(身生智、智生富、富生行、行生心、心生身),虚线标注相克(身克富、智克行、富克心、行克身、心克智)。五环用五维色系(橙/蓝/金/绿/粉)。
+风格:彩色环形信息图,文字清晰,可直接用于排版。】
+
 而当某一个维度亮了红灯,先别急着只盯着它。用五行相生相克往上游找一找"滋养不足",再往相克的方向看一看"过度压制",常常能找到真正的根源——这套"寻源"的方法,后面每一章和附录的速查表都会用到。
 
 ---
@@ -665,6 +686,11 @@
 ====================
 # 第3章:身——主动健康,守住每个人的基本盘
 
+【插图占位:IMG-03-00|第3章·章首题图|
+图名:守住基本盘
+内容:一棵扎根大地的树,树根深扎土壤("基本盘"意象),树干上站着安然的一家人;画面底部是体检报告与一杯水的小元素。橙色系("身"维度色)。
+风格:治愈系手绘插画,暖橙色调,无文字。】
+
 > **五行属土** | 土厚德载物,身是万物的根基
 >
 > 与第5章(富)形成对照:身是"省"(健康就是省钱),富是"生"(创造增量)
@@ -770,6 +796,11 @@
 
 ---
 
+【插图占位:IMG-03-01|第3章·一杯水示意图|
+图名:最小输入撬动全家健康
+内容:左侧一个小人只做一件事——端起一杯水;右侧撬起整个家庭系统:体检报告、均衡饮食、规律作息、饭后散步依次被"撬"起(杠杆式构图)。下方一行小字图注:"一杯水,就是改变全家健康的起点。"
+风格:杠杆示意图+插画结合,橙色系("身"维度色),文字仅图注一句。】
+
 ## 一杯水的启发
 
 小付真正开始改变,是从一个他自己都没想到的地方开始的——家里喝的水。
@@ -1155,6 +1186,11 @@
 ====================
 # 第4章:智——赋能未来,成长是每个人的终身课题
 
+【插图占位:IMG-04-00|第4章·章首题图|
+图名:成长的种子
+内容:一颗种子破土而出、长成幼苗,幼苗叶片上托着打开的书本;背景是孩子举手提问、家长俯身倾听、全家一起阅读的剪影。蓝金色系("智"维度色)。
+风格:治愈系手绘插画,蓝金调,无文字。】
+
 **五行属金 | 金曰从革,智在磨砺中成器**
 
 ---
@@ -1386,6 +1422,10 @@
 
 但"努力"和"方法"是可以改变的。你今天努力了,明天可以更努力。这个方法不行,换一个方法就行。慢慢地,孩子会形成一种思维模式:做不好不是我不好,是我还没找到对的方法。
 
+【插图占位:IMG-04-01|第4章·固定型思维 vs 成长型思维对比图|
+内容:左右对照。左图(冷灰调)"固定型思维":一个封闭的圆环,内部写着"我不会""我不行""算了";右图(暖色调)"成长型思维":破土而出的幼苗,叶片上写着"我还没学会""我要试试""再来一次"。两图之间一条分界线,底部一行字:"把'你真聪明'改成'你刚才用了什么方法'。"
+风格:对比信息图,左右对称,文字为图中关键话术,清晰可读。】
+
 前者叫固定型思维,后者叫成长型思维。这两种思维模式不是天生的,它是在一次次夸奖、一次次批评中,被我们这些大人捏出来的。
 
 这个道理,不只是对孩子说的。你对自己说"我就是学不会新东西"和"我只是还没找到方法",是两种完全不同的人生。小付在会议室里那个"我学不会"的念头,就是固定型思维在成年人身上的样子——它跟年龄无关,只跟你怎么解释自己有关。
@@ -1498,6 +1538,10 @@
 
 你不是在原谅对方。你是在明白一件事:他不是你,他不应该跟你一样。这个认知,比任何沟通技巧都管用。因为它从根本上改变了你看待一个人的方式——从"他不对"变成了"他不同"。
 
+【插图占位:IMG-04-02|第4章·认知画像雷达图示例|
+内容:一张六边形雷达图,标注认知维度(注意力、加工速度、记忆力、推理能力、空间能力等);图上有两条线:孩子自己的能力线 + 家长认为的线,两条线明显不同。图下小字图注:"坐下来,看完对方的报告,问一句——'原来你是这样想的。'"
+风格:数据可视化插画,蓝金色系,雷达图清晰可读。】
+
 在双培强基工程的成人测评跟踪数据中,有一个数据特别有意思:那些在认知测评后夫妻共同做过一次"认知画像"对话的家庭,三个月后对"家庭沟通满意度"的评分平均提升了1.8分(满分10分,数据来源:双培强基工程家庭关系评估报告 2025-2026)。1.8分看起来不大,但对于婚姻关系来说,这相当于从"经常觉得被误解"变成了"大部分时候感觉被理解"。而他们做的事情,只是坐下来看了对方的报告,然后说了一句:"原来你是这样想的。"
 
 有时候,一句"原来是这样"比一百句"我错了"更有力量。因为前者意味着真正的理解,后者只是表面的认输。
@@ -1674,6 +1718,11 @@
 ====================
 # 第5章:富——利他创富,家庭经济的第二曲线
 
+【插图占位:IMG-05-00|第5章·章首题图|
+图名:藏宝图与第二曲线
+内容:一张微微卷起的手绘藏宝图卷轴铺在桌上,图上标记着"我能为别人提供什么"的宝藏点(技能、经验、人脉、口碑);卷轴一端延伸出一条向上扬起的"第二收入曲线"。金色系("富"维度色)。
+风格:治愈系手绘插画,暖金调,无文字。】
+
 > **五行属水** | 水善利万物而不争,富在利他中生长
 >
 > 与第3章(身)形成对照:身是"省"(健康就是省钱),富是"生"(创造增量)
@@ -1873,6 +1922,10 @@
 
 ---
 
+【插图占位:IMG-05-01|第5章·藏宝图示例|
+内容:三个相交的圆(韦恩图):左圆"我会做什么"(技能)、右圆"我拥有什么"(经验/人脉)、下圆"别人需要什么"(需求);三圆交叠的正中区域标一颗星与"利他创富"。图下方一行字:"别画愿景图,画藏宝图——从'我能为别人提供什么'开始。"
+风格:手绘地图感信息图,金色系,文字清晰。】
+
 ## 别画愿景图,画藏宝图
 
 你肯定见过这个建议:找个安静的时间,闭上眼睛想象——你理想中的生活是什么样的?房子、车子、存款、旅行的样子……然后把它画下来,贴在床头,每天看。
@@ -1984,7 +2037,9 @@
 
 阿杰和婷婷就是这样一对夫妻。两个人都是三十出头,双职工,孩子刚上幼儿园。工资不算低,但每个月都花得精光——房贷、孩子的课外班、周末的亲子餐厅、随手点的外卖和奶茶。花呗借呗轮着还,应急金?零。他们不是没想过攒钱,是"每个月都觉得下个月能攒",然后每个月都没攒下来。
 
-改变是从一次家庭财务体检开始的。不是请理财顾问,就是坐下来,花了十分钟,算了四个数。
+【插图占位:IMG-05-02|第5章·家庭财务体检四数字卡片|
+内容:四张并排卡片,每张一个大数字+解释:①应急资金覆盖月数 ②储蓄率 ③负债收入比 ④保险覆盖;卡片下方一支铅笔勾选"完成"的样式。图注一行:"十分钟,算四个数,看清家庭底线。"
+风格:卡片式信息图,金色系,数字醒目。】不是请理财顾问,就是坐下来,花了十分钟,算了四个数。
 
 **第一项:应急资金覆盖月数** = 随时能取出的流动资产 ÷ 每月生活支出。婷婷把活期账户、余额宝、能随时取出来的钱加在一起,除以这个月的生活支出——数字出来,她愣了半天:0.2。也就是说,如果明天两个人同时失去收入,家里的钱撑不过一周。参考线:大于等于6个月健康,3-6个月及格,小于3个月警惕——这正是"家庭金融脆弱性"的临界线:经不起三个月没有收入的冲击。
 
@@ -2068,6 +2123,11 @@
 ====================
 # 第6章:行——人际和谐,以关系滋养幸福
 
+【插图占位:IMG-06-00|第6章·章首题图|
+图名:以关系滋养幸福
+内容:一棵大树的枝干间,一家三口与朋友、邻居围坐,人与人之间是温柔的连接线(像血管一样输送养分);远处是同事与老友的形象。绿色系("行"维度色)。
+风格:治愈系手绘插画,绿色调,无文字。】
+
 **五行属木 | 木曰曲直,行贵在舒展通达**
 
 ---
@@ -2244,6 +2304,10 @@
 
 这些问题没有标准答案。但答案不重要,重要的是——你们在问彼此。
 
+【插图占位:IMG-06-01|第6章·第三空间示意图|
+内容:三个圆的位置关系图:大圆"家庭"、大圆"工作/育儿",两圆之外一个独立的小圆"第三空间"(画着咖啡馆/河边长椅),两个大圆之间有一扇小门通往第三空间。图注一行:"每周30分钟'不谈家事'的对话,比任何技巧都有效。"
+风格:简约位置示意插画,绿色系,文字清晰。】
+
 ### 方法二:共同创造"第三空间"
 
 什么是"第三空间"?家庭和工作之外,一个只属于你们两个人的精神角落。
@@ -2610,6 +2674,10 @@
 
 每周花两分钟,想一想:这周我给这段关系存了什么款?
 
+【插图占位:IMG-06-02|第6章·关系能量账户示意图|
+内容:一个"情感储蓄罐"左右分栏:左侧"存款"(一个拥抱、一句辛苦了、一杯水、一条消息)往下落入罐中;右侧"取款"(指责、冷战、敷衍)从罐中拿出;罐身一条5:1比例刻度条,存款端明显更高。图注一行:"小额存款,比偶尔一次大额存款重要得多。"
+风格:储蓄罐插画+比例条,绿色系,小图标简洁。】
+
 不一定是什么大事。一个拥抱、一句"你今天辛苦了"、一杯水、一条消息说"今天天气好,想到你了"。这些小额存款,比偶尔一次"大额存款"(比如送贵重礼物)重要得多。
 
 反过来,如果你发现自己最近对某个人频繁"取款"——总是批评、总是挑剔、总是冷漠——那是一个信号:这段关系亮红灯了。先存三笔款,再说话。
@@ -2714,12 +2782,20 @@
 
 > **金句:关系不是等出来的,是按节奏长出来的。24小时联系、7天见面、30天一次小求助——三步走完,陌生人就变成了熟人。**
 
+【插图占位:IMG-06-03|第6章·章鱼图示例|
+内容:纸中央一个圆(里面写"我"),向外伸出4-6条弯曲的章鱼触手,每条触手上写一种关系的示例(同事/朋友/亲戚/邻居/老师/旧同学),触手末端点缀小爱心。图注一行:"你不是一个人在硬撑——你身后一直有一群托举过你的人。"
+风格:手绘趣味示意图,绿色系,圆润可爱。】
+
 #### 两幅图:章鱼图与珍珠图
 
 最后,送给你两个"关系盘点"的工具——画图,比列清单更能看清。
 
 **章鱼图:谁帮助过我。** 在一张纸中间画一个圆(是你自己),再画出几条章鱼触手。每条触手上,写一个帮助过你的人——不一定是"大恩人",给你递过一把伞的同事、帮你改过稿子的朋友、借过你钱的亲戚,都算。定期(比如半年)重画一次。画完之后你会发现:**你不是一个人在硬撑**——你身后一直有一群托举过你的人。这张图治的是"我没人可求"的错觉。
 
+【插图占位:IMG-06-04|第6章·珍珠图示例|
+内容:一条弧线像项链一样串起一颗颗珍珠,每颗珍珠上写一种资源:认识的一个人、掌握的一条信息、去过的一个地方、学过的一项技能;项链两端是一双手轻轻托住。图注一行:"你手里的牌,比你以为的多。"
+风格:手绘趣味示意图,与章鱼图风格统一,珍珠圆润有光泽感。】
+
 **珍珠图:有哪些资源。** 把你能调动的资源,像珍珠一样一颗颗串起来。不是只有钱才算资源——认识的一个人、掌握的一条信息、去过的一个地方、学过的一项技能,都是珍珠。画完你会看清:你手里的牌,比你以为的多。这张图治的是"我什么都没有"的错觉。
 
 章鱼图回答"谁帮过我",珍珠图回答"我有什么"。两张图放在一起,就是你的"关系资产表"——平时每月看一眼,遇事时不慌,因为你知道自己有几条触手、几颗珍珠。
@@ -2822,6 +2898,11 @@
 ====================
 # 第7章:心——大爱相随,让家成为真正的港湾
 
+【插图占位:IMG-07-00|第7章·章首题图|
+图名:家的港湾
+内容:夜晚的暴风雨中,一栋小房子的窗户透出温暖的橙粉光,像海边的灯塔;屋内有家人围坐的身影,窗外风雨交加但屋里安宁。粉色系("心"维度色)。
+风格:治愈系手绘插画,暖粉调,光影对比明显,无文字。】
+
 **五行属火 | 火曰炎上,心以温暖照亮家族**
 
 如果说一个家庭的幸福是一座建筑,那么"身"是地基,"智"是梁柱,"富"是墙砖,"行"是窗扉,而"心",则是这座房子里的温度。
@@ -2983,6 +3064,10 @@
 
 既然爱是一种能力,那么它就可以通过具体的工具来练习。
 
+【插图占位:IMG-07-01|第7章·情绪停机坪示意图|
+内容:一条情绪曲线从平静急速爬升到爆发点,在爆发点之前有一个醒目的"暂停键"(圆形按钮,内画暂停图标)横在曲线上;暂停键下方三步小图:①停下来 ②深呼吸 ③说出感受。图注一行:"在爆发前按下暂停键。"
+风格:曲线+按钮信息图,粉色系,图标简洁。】
+
 ### 1. 情绪停机坪:在爆发前按下暂停键
 
 为了对抗那几毫秒的"情绪脑劫持",我们需要在刺激和反应之间,建立一个"停机坪"。
@@ -3044,6 +3129,10 @@
 
 **关自己,看起来是示弱,其实是示范。** 你不需要做一个永远不会生气的完美家长。你只需要做一个生气了知道怎么处理的真实家长。
 
+【插图占位:IMG-07-02|第7章·闻花香+吹蜡烛法示意图|
+内容:左右两幅对照小图:左图一个人面前一朵茉莉花,箭头向上表示"深深吸气";右图同一人面前一支点燃的蜡烛,箭头向前表示"缓缓呼气吹灭"。两图之间一个箭头连接。图注一行:"最简单的情绪开关。"
+风格:两格漫画式插画,粉色系,动作夸张可爱。】
+
 ### 2. 闻花香 + 吹蜡烛法:最简单的情绪开关
 
 对于很多家长来说,单纯的"理性思考"在极度愤怒时是很难实现的。这时,我们需要一个生理层面的"强制重启"开关。
@@ -3056,6 +3145,10 @@
 
 在我们的实践案例中,有一个习惯使用"情绪开关"的家庭,连续练习7天后,吼叫频率从每周5次降到了1次。因为他们发现,掌控情绪带来的掌控感,远比吼叫带来的权力感更令人愉悦。
 
+【插图占位:IMG-07-03|第7章·4-7-8呼吸法节奏图|
+内容:一条平滑的时间轴波形,分三段色块:吸气4秒(短弧上升)、屏息7秒(平直段)、呼气8秒(长弧下降);三段下方分别标注"吸气 4秒""屏息 7秒""呼气 8秒",顶部一行小字"4-7-8"。图注一行:"给心灵的深度按摩。"
+风格:波形节奏信息图,粉色系,秒数标注清晰。】
+
 ### 3. 4-7-8 呼吸法:给心灵的深度按摩
 
 如果说"闻花香"是应对突发愤怒的急救药,那么 **4-7-8 呼吸法**(源自哈佛大学的放松技术)则是日常的滋养剂。
@@ -3137,6 +3230,11 @@
 ====================
 # 第8章:最小输入——小到不可能失败
 
+【插图占位:IMG-08-00|第8章·章首题图|
+图名:轻轻一推
+内容:一列多米诺骨牌,第一块最小最轻,一根手指正要轻轻推倒它;骨牌由小到大依次倒下,最后一块最大,倒向一双跑鞋。画面传达"最小的动作撬动最大的改变"。
+风格:治愈系手绘插画,橙绿色调,动态感,无文字。】
+
 > **算法类比:`min_input()`——任何大于0的输入都比0好。程序启动需要最小的有效输入,家庭改变也是一样。**
 
 ---
@@ -3191,6 +3289,10 @@
 
 每天早上起来,喝一杯温水。不是两升,不是柠檬水,不是蜂蜜水。就是一杯温水。看起来太简单了,简单到你觉得"这有什么用"。但你只要做这一件事,它就改变了你对健康的态度——你在主动照顾自己的身体。这个信号本身,比那杯水的生理作用更重要。
 
+【插图占位:IMG-08-01|第8章·五维最小输入清单卡片|
+内容:五张竖排卡片,每张一个维度色+小图标+一行字:身(橙)喝一杯水、智(蓝)问一个开放题、富(金)记一笔账、行(绿)给家人发一条消息、心(粉)深呼吸一次。卡片底部一行统一小字:"小到不可能失败,小到不需要意志力。"
+风格:卡片式清单信息图,五维色系,图标简洁,文字清晰。】
+
 **智的最小输入:问一个开放题**
 
 不用每天给孩子辅导两小时功课。只需要在晚饭时或睡前,问一个打开的问题——"今天有没有一件事让你觉得有意思?"十秒钟就够了。但孩子在这个问题里感受到的,是你对他这个人感兴趣,而不只是对他的分数感兴趣。
@@ -3358,6 +3460,11 @@ def 小付的优化版本():
 ====================
 # 第9章:循环执行——让好习惯自动运行
 
+【插图占位:IMG-09-00|第9章·章首题图|
+图名:习惯的循环
+内容:一个旋转的环形箭头齿轮,齿轮中心是晨跑的人;环形上四个节点各有一个小图标:闹钟(触发)、跑鞋(动作)、多巴胺火花(奖励)、发光的灯泡(信念),环形箭头顺时针转动。
+风格:循环结构插画,绿色调,动感,无文字。】
+
 > **算法类比:`while(habit) { execute(); reward(); }`——循环体决定结果。写程序如此,养成习惯也是如此。**
 
 ---
@@ -3390,6 +3497,10 @@ def 小付的优化版本():
 
 习惯养成就是写一个属于自己的循环。条件是你的"触发信号",循环体是你的"执行动作",而更新条件的那个步骤——是你的"奖励"。
 
+【插图占位:IMG-09-01|第9章·习惯回路四要素图|
+内容:环形图四个节点:触发(床边跑鞋/闹钟)→ 动作(穿上跑鞋出门)→ 奖励(多巴胺火花+成就感)→ 信念(发光的灯泡,内写"跑步对我有好处"),环形箭头闭环,中心标注"习惯回路"。图注一行:"循环执行不是加大剂量,是保持剂量不变。"
+风格:环形信息图,绿色系,四节点图标清晰。】
+
 ## 习惯回路四要素
 
 小付后来了解到一个模型,叫"习惯回路"。它不是他发明的,是麻省理工学院的研究者在上世纪九十年代用老鼠实验发现的,后来被记者出身的查尔斯·杜希格(Charles Duhigg)写进《习惯的力量》一书而广为人知(Duhigg,The Power of Habit, 2012)。简单的说,每一个习惯都由四个要素组成:
@@ -3592,6 +3703,11 @@ def 家庭协同循环(成员列表, 每周复盘日=7):
 ====================
 # 第10章:反馈校验——看见改变正在发生
 
+【插图占位:IMG-10-00|第10章·章首题图|
+图名:看见改变
+内容:一面立镜,镜中映出不断上升的折线图(从低谷爬升);一只手正指着曲线上扬的转折点,镜框边角贴着一张小小的月度打分便签。
+风格:治愈系手绘插画,蓝绿色调,数据感与温度并存,无文字。】
+
 > **算法类比:`console.log(status)`——没有日志的程序无法调试。没有反馈的改变无法持续。**
 
 ---
@@ -3610,6 +3726,10 @@ def 家庭协同循环(成员列表, 每周复盘日=7):
 
 > **金句:放弃不是因为累,是因为看不见——看不见自己已经在变好。**
 
+【插图占位:IMG-10-01|第10章·反馈闭环示意图|
+内容:四个圆角方块环形排列,箭头闭环:行为(一个人行动)→ 数据(记录本/图表)→ 感知(眼睛/心形)→ 强化(向上的箭头)→ 回到行为。图注一行:"大多数人缺的不是行为,是数据。"
+风格:闭环信息图,蓝绿色调,四方块图标简洁。】
+
 ## 反馈闭环
 
 反馈不是一句简单的"你做得好"或"你做得不好",它是一个闭环。这个闭环有四个环节:
@@ -3639,6 +3759,10 @@ def 家庭协同循环(成员列表, 每周复盘日=7):
 
 这个小小的记录,改变了他对"正在发生什么"的认知。
 
+【插图占位:IMG-10-02|第10章·五维能量地图示意图|
+内容:一张月度网格纸上,五条彩色折线延伸:身(橙)、智(蓝)、富(金)、行(绿)、心(粉),每条线由每月一个圆点连成,整体呈缓慢上升趋势;纸面边缘一支笔。图注一行:"每月打分,连点成线,季度看趋势——不看单次,看方向。"
+风格:手账式数据可视化插画,五维色系,页面留白透气。】
+
 ## 五维能量地图
 
 小付的笔记本记录了他个人的变化。但家庭是一个系统,他需要一个更大的视角——五维能量地图。
@@ -3841,6 +3965,11 @@ def 家庭反馈系统(成员们):
 ====================
 # 第11章:系统升级——从"我试过"到"我就是"
 
+【插图占位:IMG-11-00|第11章·章首题图|
+图名:版本迭代
+内容:一个家庭系统仪表盘/控制面板,面板角落三个递进的版本标签:v1.0 → v1.1 → v1.2;面板上的"功能"都是家里真实的小事(晨间一杯水、复盘饭、记账、五维打分)的图标,每次升级只多一两个图标,不推倒重来。
+风格:仪表盘扁平插画,橙绿蓝混合暖调,无文字(版本号除外)。】
+
 > **算法类比:v1.0 → v1.1 → v1.2。不是重写系统,是持续迭代——家庭幸福的版本号,从来不跳大版本。**
 
 ---
@@ -4047,6 +4176,11 @@ def 家庭反馈系统(成员们):
 ====================
 # 第12章:端口开放——把家庭能量释放出去
 
+【插图占位:IMG-12-00|第12章·章首题图|
+图名:打开端口
+内容:一栋小房子打开了好几扇窗和一扇门,温暖的金色光点从窗口/门口(端口)流淌出去,流向街坊、朋友、社区;屋外有人伸出手接过光点。画面传达"家庭能量对外释放"。
+风格:治愈系手绘插画,暖金色调,光点流动感,无文字。】
+
 > **算法类比:`server.listen(PORT)`——一个只监听本地回环地址的服务,再稳定也没有用。家庭也需要对外开放端口,才能产生真正的价值。**
 
 ---
@@ -4232,6 +4366,11 @@ class 家庭端口:
 ====================
 # 第13章:网络效应——你的幸福是最好的传播
 
+【插图占位:IMG-13-00|第13章·章首题图|
+图名:幸福网络
+内容:画面中心一个发光的家庭节点(一家人的剪影),连接线向四周扩散到越来越多的节点:朋友 → 朋友的朋友 → 陌生人;越往外节点越多、光越亮,形成 n² 式扩散的网络。每个节点上都有一点暖光。
+风格:网络节点插画,暖色光效,扩散感,无文字。】
+
 > **算法类比:梅特卡夫定律——网络的价值 ≈ n²。一个幸福的家庭不只是它自己幸福,它会成为更多人改变的开始。**
 
 ---
@@ -4401,6 +4540,11 @@ def 家庭系统社会价值():
 ====================
 # 终章:绘制你家的幸福全景图
 
+【插图占位:IMG-14-00|终章·章首题图|
+图名:幸福全景图
+内容:翻开一本封面印着猫的笔记本,展开的页面上是完整的五维能量地图——五条彩色折线(橙蓝金绿粉)在一年多的网格上蜿蜒,汇聚成一张完整的"幸福全景图";一支笔尖正停在最新的一格。呼应序章与第10章的五维能量地图。
+风格:手账式治愈插画,五维色系,全书"收束"感,无文字。】
+
 > **只有全景,才能对话复杂性。**
 
 ---
@@ -4674,6 +4818,10 @@ def 家庭系统社会价值():
 ====================
 # 附录:家庭幸福全景工具包
 
+【插图占位:IMG-15-00|附录·工具包总览图|
+内容:一个打开的行李箱(工具箱),里面整齐摆着全书所有工具的迷你图标:一杯水、藏宝图、章鱼图、珍珠图、情绪停机坪暂停键、4-7-8波形、五维能量地图、财务体检卡片、跑鞋、家庭角色轮值表……每件工具旁边有一张小小的"使用说明"便签。图注一行:"工具不在多,能用一个就先用一个。"
+风格:手绘"百宝箱"插画,暖色调,细节丰富可供读者逐件辨认,呼应全书。】
+
 > **可撕下的实用工具集——不需要重读全书,遇到问题直接翻这一章。**
 
 ---

+ 2 - 1
cfc-backend/config/application-prod.yml

@@ -29,4 +29,5 @@ admin:
   skip-captcha: false
 
 wechat:
-  test-mode: false
+  test-mode: false
+  appkey: ${WECHAT_APPKEY:}

+ 2 - 0
cfc-backend/config/application.yml

@@ -52,6 +52,8 @@ wechat:
   login-url: https://api.weixin.qq.com/sns/jscode2session
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   test-mode: true
+  appkey: ${WECHAT_APPKEY:}                # 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开)
+  virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0}   # 0=现网 1=沙箱
   test-phone: "13800138000"
   alert-template-id: ${WECHAT_ALERT_TEMPLATE_ID:}
 

+ 55 - 52
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -2152,9 +2152,6 @@ log.info("已添加template_id列到tasks表");
             }
         }
 
-        // 关系类型表添加 default_role 列(角色模板)
-        ensureColumn("relationship_types", "default_role", "VARCHAR(20) DEFAULT NULL COMMENT '默认角色模板: parent/child'");
-
         // 家庭成员表添加 role_override 列(替换旧 capability_role)
         ensureColumn("family_members", "role_override", "VARCHAR(20) DEFAULT 'auto' COMMENT '角色覆盖: auto/parent/child/elderly'");
         try {
@@ -2860,34 +2857,6 @@ log.info("已添加template_id列到tasks表");
             log.warn("创建member_discount_configs表失败: {}", e.getMessage());
         }
 
-        try {
-            Integer hasCap = jdbcTemplate.queryForObject(
-                "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'relationship_types' AND COLUMN_NAME = 'capability_role'",
-                Integer.class);
-            Integer hasDefault = jdbcTemplate.queryForObject(
-                "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'relationship_types' AND COLUMN_NAME = 'default_role'",
-                Integer.class);
-            if (hasCap != null && hasCap == 0) {
-                if (hasDefault != null && hasDefault > 0) {
-                    jdbcTemplate.execute("ALTER TABLE relationship_types CHANGE COLUMN default_role capability_role VARCHAR(20) NOT NULL COMMENT '能力角色: parent/child'");
-                    log.info("已将relationship_types.default_role重命名为capability_role");
-                } else {
-                    jdbcTemplate.execute("ALTER TABLE relationship_types ADD COLUMN capability_role VARCHAR(20) NOT NULL DEFAULT 'parent' COMMENT '能力角色: parent/child' AFTER type_name");
-                    log.info("已添加capability_role列到relationship_types表");
-                }
-            }
-        } catch (Exception e) {
-            log.warn("relationship_types capability_role 迁移失败: {}", e.getMessage());
-        }
-
-        // 确保capability_role有默认值(生产库该列NOT NULL无默认值,导致INSERT时Field doesn't have a default value错误)
-        try {
-            jdbcTemplate.execute("ALTER TABLE relationship_types MODIFY COLUMN capability_role VARCHAR(20) NOT NULL DEFAULT 'parent' COMMENT '能力角色: parent/child'");
-            log.info("已修改relationship_types.capability_role添加默认值'parent'");
-        } catch (Exception e) {
-            log.warn("修改relationship_types.capability_role默认值失败: {}", e.getMessage());
-        }
-
         // ==================== 食材推荐指数 Phase: food_recommend_idx / food_recommend_idx_log ====================
         try {
             jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS `food_recommend_idx` (" +
@@ -3144,8 +3113,8 @@ log.info("已添加template_id列到tasks表");
         modifyColumnToText("guide_applications", "reject_reason", "拒绝原因");
         // guide_application_records.remark
         modifyColumnToText("guide_application_records", "remark", "备注/原因");
-        // user_operation_log.description
-        modifyColumnToText("user_operation_log", "description", "操作描述");
+        // user_operation_logs.description
+        modifyColumnToText("user_operation_logs", "description", "操作描述");
         // follow_up.description, teacher_review
         modifyColumnToText("follow_up", "description", "描述");
         modifyColumnToText("follow_up", "teacher_review", "规划师点评");
@@ -3178,12 +3147,12 @@ log.info("已添加template_id列到tasks表");
         // life_number_relationship.description, advice
         modifyColumnToText("life_number_relationship", "description", "关系解读");
         modifyColumnToText("life_number_relationship", "advice", "相处建议");
-        // daily_health_checkin.remark
-        modifyColumnToText("daily_health_checkin", "remark", "备注");
-        // health_sleep_record.remark
-        modifyColumnToText("health_sleep_record", "remark", "备注");
-        // health_exercise_record.remark
-        modifyColumnToText("health_exercise_record", "remark", "备注");
+        // health_checkins.note
+        modifyColumnToText("health_checkins", "note", "备注");
+        // sleep_record.remark
+        modifyColumnToText("sleep_record", "remark", "备注");
+        // exercise_record.remark
+        modifyColumnToText("exercise_record", "remark", "备注");
         // finance_checkins.note
         modifyColumnToText("finance_checkins", "note", "备注");
         // guide_packages.description (old duplicate)
@@ -3191,18 +3160,12 @@ log.info("已添加template_id列到tasks表");
         modifyColumnToText("assessment_appointments", "remark", "备注");
         // withdrawal_requests.remark
         modifyColumnToText("withdrawal_requests", "remark", "备注");
-        // supply_settlements.remark
-        modifyColumnToText("supply_settlements", "remark", "备注");
+        // supply_settlement.remark
+        modifyColumnToText("supply_settlement", "remark", "备注");
         // content_sections.description
         ensureColumn("content_sections", "description", "TEXT COMMENT '描述说明'");
         // education_systems.description
         modifyColumnToText("education_systems", "description", "描述");
-        // dimension_config.description
-        modifyColumnToText("dimension_config", "description", "描述");
-        // energy_rules.description
-        modifyColumnToText("energy_rules", "description", "规则描述");
-        // team_goals.description
-        modifyColumnToText("team_goals", "description", "团队描述");
         // product_dimension_config.remark
         modifyColumnToText("product_dimension_config", "remark", "备注");
         // indicator_values.remark
@@ -5426,11 +5389,6 @@ try {
             log.info("已创建social_circle_member表");
         } catch (Exception e) { /* 表已存在忽略 */ }
 
-        // 迁移89: relationship_types表添加分类和血亲字段
-        ensureColumn("relationship_types", "category", "VARCHAR(50) DEFAULT 'social' COMMENT '关系分类: blood血亲/marriage姻亲/social社会关系'");
-        ensureColumn("relationship_types", "is_blood_relation", "TINYINT DEFAULT 0 COMMENT '是否血亲关系: 1是0否'");
-        ensureColumn("relationship_types", "is_immediate_family", "TINYINT DEFAULT 0 COMMENT '是否直系亲属: 1是0否'");
-
     // 迁移96: 创建活动订单评价表
     try {
         jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS review_orders (" +
@@ -6191,6 +6149,16 @@ try {
     } catch (Exception e) {
         log.warn("修正主表dimension_weights JSON失败: {}", e.getMessage());
     }
+
+    // 迁移142: package_orders.user_id/family_id/package_id改为可空(匿名肠道菌群检测支付流程无用户/家庭/套餐)
+    try {
+        jdbcTemplate.execute("ALTER TABLE package_orders MODIFY COLUMN user_id BIGINT DEFAULT NULL COMMENT '购买者用户ID'");
+        jdbcTemplate.execute("ALTER TABLE package_orders MODIFY COLUMN family_id BIGINT DEFAULT NULL COMMENT '家庭ID'");
+        jdbcTemplate.execute("ALTER TABLE package_orders MODIFY COLUMN package_id BIGINT DEFAULT NULL COMMENT '套餐ID'");
+        log.info("已修改package_orders.user_id/family_id/package_id为可空");
+    } catch (Exception e) {
+        log.warn("修改package_orders可空列失败: {}", e.getMessage());
+    }
     }
 
     private void runMigration82() {
@@ -7300,5 +7268,40 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("修改activity_registrations.child_id失败: {}", e.getMessage());
 		}
+		// 迁移119: package_orders表添加联系人字段(产品页下单)
+		try {
+			ensureColumn("package_orders", "contact_name", "VARCHAR(50) COMMENT '联系人姓名'");
+			ensureColumn("package_orders", "contact_phone", "VARCHAR(20) COMMENT '联系人手机号'");
+			ensureColumn("package_orders", "contact_address", "VARCHAR(500) COMMENT '收件地址'");
+			log.info("已完成package_orders表联系人字段添加");
+		} catch (Exception e) {
+			log.warn("添加package_orders联系人字段失败: {}", e.getMessage());
+		}
+
+		// 迁移120: activities表添加registration_deadline列(活动中心-报名截止时间展示)
+		try {
+			ensureColumn("activities", "registration_deadline", "DATETIME COMMENT '报名截止时间'");
+			log.info("已完成activities.registration_deadline字段添加");
+		} catch (Exception e) {
+			log.warn("添加activities.registration_deadline字段失败: {}", e.getMessage());
+		}
+
+		// 迁移143: 创建小程序虚拟支付道具映射表
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS virtual_goods_config (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"goods_type VARCHAR(32) NOT NULL COMMENT 'ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION', " +
+					"biz_key VARCHAR(64) NOT NULL COMMENT '业务SKU标识: 测评套餐ID / 会员等级code / 订阅档位code', " +
+					"product_id VARCHAR(64) NOT NULL COMMENT '微信商户后台道具ID', " +
+					"goods_name VARCHAR(128) NOT NULL COMMENT '道具名称', " +
+					"status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用', " +
+					"create_time DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+					"UNIQUE KEY uk_goods_type_biz (goods_type, biz_key)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序虚拟支付道具映射'");
+			log.info("已创建virtual_goods_config表");
+		} catch (Exception e) {
+			log.warn("创建virtual_goods_config表失败: {}", e.getMessage());
+		}
 	}
 }

+ 91 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/ErrorLoggingFilter.java

@@ -0,0 +1,91 @@
+package com.etotem.cfc.config;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.*;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+@Component
+@Order(Integer.MIN_VALUE + 1)
+public class ErrorLoggingFilter implements Filter {
+
+    private static final Logger log = LoggerFactory.getLogger(ErrorLoggingFilter.class);
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+            throws IOException, ServletException {
+
+        HttpServletRequest req = (HttpServletRequest) request;
+        HttpServletResponse resp = (HttpServletResponse) response;
+
+        if (!shouldLog(req)) {
+            chain.doFilter(request, response);
+            return;
+        }
+
+        StatusExposingHttpServletResponse wrapped = new StatusExposingHttpServletResponse(resp);
+        Throwable caught = null;
+
+        try {
+            chain.doFilter(request, wrapped);
+        } catch (Throwable t) {
+            caught = t;
+            log.error("HTTP {} {} {} - 异常: {} - 返回 500",
+                    req.getMethod(), req.getRequestURI(),
+                    req.getQueryString() != null ? "?" + req.getQueryString() : "",
+                    t.getClass().getName() + ": " + t.getMessage());
+            // 继续传播,让 Spring 处理异常
+            throw t;
+        }
+
+        int status = wrapped.getStatus();
+
+        if (caught == null && status >= 500) {
+            log.error("HTTP {} {} {} - 返回 {}", req.getMethod(), req.getRequestURI(),
+                    req.getQueryString() != null ? "?" + req.getQueryString() : "", status);
+        } else if (caught == null && status >= 400) {
+            log.warn("HTTP {} {} {} - 返回 {}", req.getMethod(), req.getRequestURI(),
+                    req.getQueryString() != null ? "?" + req.getQueryString() : "", status);
+        }
+    }
+
+    private boolean shouldLog(HttpServletRequest req) {
+        String uri = req.getRequestURI();
+        return uri.startsWith("/api/") && !"OPTIONS".equals(req.getMethod());
+    }
+
+    private static class StatusExposingHttpServletResponse extends javax.servlet.http.HttpServletResponseWrapper {
+        private int status = 200;
+
+        public StatusExposingHttpServletResponse(HttpServletResponse response) {
+            super(response);
+        }
+
+        @Override
+        public void setStatus(int code) {
+            this.status = code;
+            super.setStatus(code);
+        }
+
+        @Override
+        public void sendError(int code) throws IOException {
+            this.status = code;
+            super.sendError(code);
+        }
+
+        @Override
+        public void sendError(int code, String message) throws IOException {
+            this.status = code;
+            super.sendError(code, message);
+        }
+
+        public int getStatus() {
+            return status;
+        }
+    }
+}

+ 10 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/GlobalExceptionHandler.java

@@ -1,14 +1,24 @@
 package com.etotem.cfc.config;
 
 import com.etotem.cfc.common.Result;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.web.bind.annotation.ExceptionHandler;
 import org.springframework.web.bind.annotation.RestControllerAdvice;
 
 @RestControllerAdvice
 public class GlobalExceptionHandler {
 
+    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
     @ExceptionHandler(IllegalArgumentException.class)
     public Result<Void> handleIllegalArgumentException(IllegalArgumentException e) {
         return Result.error(400, e.getMessage());
     }
+
+    @ExceptionHandler(Exception.class)
+    public Result<Void> handleException(Exception e) {
+        log.error("未捕获异常: {} - {}", e.getClass().getName(), e.getMessage(), e);
+        return Result.error("服务器内部错误: " + e.getMessage());
+    }
 }

+ 31 - 2
cfc-backend/src/main/java/com/etotem/cfc/config/WebConfig.java

@@ -1,6 +1,7 @@
 package com.etotem.cfc.config;
 
-import javax.annotation.Resource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
@@ -11,13 +12,17 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
 import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
+import javax.annotation.Resource;
 import javax.servlet.*;
+import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 
 @Configuration
 public class WebConfig implements WebMvcConfigurer {
 
+    private static final Logger log = LoggerFactory.getLogger(WebConfig.class);
+
     @Resource
     private JwtInterceptor jwtInterceptor;
 
@@ -33,7 +38,7 @@ public class WebConfig implements WebMvcConfigurer {
     @Override
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/**")
-                .allowedOriginPatterns("https://cfc.etotem.com.cn", "http://cfc.etotem.com.cn", "https://cf-club.iwintrue.com", "http://cf-club.iwintrue.com", "http://localhost:*", "http://127.0.0.1:*")
+                .allowedOriginPatterns("https://cfc.etotem.com.cn", "http://cfc.etotem.com.cn", "https://cf-club.iwintrue.com", "http://cf-club.iwintrue.com", "https://www.cf-club.com", "http://www.cf-club.com", "https://cfc.cf-club.com", "http://cfc.cf-club.com", "http://localhost:*", "http://127.0.0.1:*")
                 .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                 .allowedHeaders("*")
                 .allowCredentials(true)
@@ -48,6 +53,7 @@ public class WebConfig implements WebMvcConfigurer {
             @Override
             public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
                     throws IOException, ServletException {
+                HttpServletRequest req = (HttpServletRequest) request;
                 HttpServletResponse resp = (HttpServletResponse) response;
                 resp.setHeader("Access-Control-Allow-Private-Network", "true");
                 chain.doFilter(request, response);
@@ -55,6 +61,29 @@ public class WebConfig implements WebMvcConfigurer {
         };
     }
 
+    /** 诊断过滤器:记录所有请求的来源和状态码 */
+    @Bean
+    @Order(Ordered.HIGHEST_PRECEDENCE + 1)
+    public Filter diagnosticFilter() {
+        return new Filter() {
+            @Override
+            public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+                    throws IOException, ServletException {
+                HttpServletRequest req = (HttpServletRequest) request;
+                long start = System.currentTimeMillis();
+                chain.doFilter(request, response);
+                int status = ((HttpServletResponse) response).getStatus();
+                long ms = System.currentTimeMillis() - start;
+                if (status == 403 || req.getRequestURI().contains("/api/payment")) {
+                    log.warn("[DIAG] {} {} -> {} ({}ms) origin={} ua={}",
+                            req.getMethod(), req.getRequestURI(), status, ms,
+                            req.getHeader("Origin"),
+                            req.getHeader("User-Agent"));
+                }
+            }
+        };
+    }
+
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
         registry.addInterceptor(jwtInterceptor)

+ 8 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/ActivityController.java

@@ -17,11 +17,15 @@ public class ActivityController {
     @Resource private ActivityRegistrationService registrationService;
 
     @PostMapping("/list")
-    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
+    public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params,
+                                            @RequestAttribute(value = "userId", required = false) Long userId,
+                                            @RequestAttribute(value = "role", required = false) String role) {
         String dimensionCode = (String) params.get("dimensionCode");
+        String status = (String) params.get("status");
+        boolean mine = params.get("mine") != null && Boolean.parseBoolean(params.get("mine").toString());
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
-        return activityService.list(dimensionCode, page, size, userId);
+        return activityService.list(dimensionCode, status, mine, page, size, userId, role);
     }
 
     @PostMapping("/detail")
@@ -105,9 +109,10 @@ public class ActivityController {
     @PostMapping("/my-registrations")
     public Result<Map<String, Object>> myRegistrations(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
         Long childId = params.get("childId") != null ? Long.valueOf(params.get("childId").toString()) : null;
+        String status = (String) params.get("status");
         Integer page = params.get("page") != null ? Integer.valueOf(params.get("page").toString()) : 1;
         Integer size = params.get("size") != null ? Integer.valueOf(params.get("size").toString()) : 10;
-        return registrationService.getMyRegistrations(userId, childId, page, size);
+        return registrationService.getMyRegistrations(userId, childId, page, size, status);
     }
 
     @PostMapping("/checkin")

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -32,6 +32,7 @@ import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
 import com.etotem.cfc.service.TongueDiagnosisService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -104,6 +105,9 @@ public class HealthReportController {
     @Resource
     private HealthReportDraftMapper healthReportDraftMapper;
 
+    @Resource
+    private WechatServiceInterface wechatService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -424,6 +428,16 @@ public class HealthReportController {
         try {
             // 图片上传:保存文件 + 创建空草稿(无自动解析)
             if ("image".equals(type) || isImageContent(contentType)) {
+                // 图片内容安全检测
+                try {
+                    byte[] imageBytes = file.getBytes();
+                    if (!wechatService.checkImage(imageBytes)) {
+                        return Result.error("图片内容包含敏感信息,上传失败");
+                    }
+                } catch (IOException e) {
+                    return Result.error("图片读取失败: " + e.getMessage());
+                }
+
                 String fileUrl = saveUploadFile(file, userId);
                 String reportType = familyId != null ? "physical_exam" : "gut_flora";
 

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MediaController.java

@@ -3,12 +3,14 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.MediaRecord;
 import com.etotem.cfc.service.MediaRecordService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 
@@ -20,6 +22,9 @@ public class MediaController {
     @Resource
     private MediaRecordService mediaRecordService;
 
+    @Resource
+    private WechatServiceInterface wechatService;
+
     @Operation(summary = "上传媒体文件")
     @PostMapping("/upload")
     public Result<MediaRecord> uploadMedia(
@@ -33,6 +38,18 @@ public class MediaController {
             return Result.error("文件大小超过限制");
         }
 
+        // 图片内容安全检测
+        if (file.getContentType() != null && file.getContentType().startsWith("image")) {
+            try {
+                byte[] imageBytes = file.getBytes();
+                if (!wechatService.checkImage(imageBytes)) {
+                    return Result.error("图片内容包含敏感信息,上传失败");
+                }
+            } catch (IOException e) {
+                return Result.error("图片读取失败: " + e.getMessage());
+            }
+        }
+
         MediaRecord record = mediaRecordService.uploadMedia(file, taskId, creatorId, description);
         return Result.success(record);
     }
@@ -44,6 +61,11 @@ public class MediaController {
         Long creatorId = Long.parseLong(body.get("creatorId").toString());
         String content = body.get("content").toString();
 
+        // 文本内容安全检测
+        if (!content.isEmpty() && !wechatService.checkText(content)) {
+            return Result.error("文本内容包含敏感信息,上传失败");
+        }
+
         MediaRecord record = mediaRecordService.saveTextRecord(taskId, creatorId, content);
         return Result.success(record);
     }

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ServiceRoleApplyDTO;
 import com.etotem.cfc.entity.ServiceRoleApplication;
 import com.etotem.cfc.service.ServiceRoleApplicationService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -26,6 +27,9 @@ public class ServiceRoleApplicationController {
     @Resource
     private ServiceRoleApplicationService serviceRoleApplicationService;
 
+    @Resource
+    private WechatServiceInterface wechatService;
+
     @PostMapping("/apply")
     @Operation(summary = "提交服务角色申请")
     public Result apply(@RequestAttribute("userId") Long userId,
@@ -46,6 +50,19 @@ public class ServiceRoleApplicationController {
         if (file.isEmpty()) {
             return Result.error("请选择文件");
         }
+
+        // 图片内容安全检测
+        if (file.getContentType() != null && file.getContentType().startsWith("image")) {
+            try {
+                byte[] imageBytes = file.getBytes();
+                if (!wechatService.checkImage(imageBytes)) {
+                    return Result.error("图片内容包含敏感信息,上传失败");
+                }
+            } catch (IOException e) {
+                return Result.error("图片读取失败: " + e.getMessage());
+            }
+        }
+
         try {
             String uploadDir = System.getProperty("user.dir") + "/uploads/service-role/" + userId;
             File dirFile = new File(uploadDir);

+ 26 - 5
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminActivityRegistrationController.java

@@ -12,7 +12,10 @@ public class AdminActivityRegistrationController {
     @Resource private ActivityRegistrationService registrationService;
 
     @PostMapping("/registration/list")
-    public Result<?> list(@RequestBody Map<String, Object> params) {
+    public Result<?> list(@RequestBody Map<String, Object> params, @RequestAttribute("role") String role) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("无权限:仅活动管理员可查看报名");
+        }
         Long activityId = params.get("activityId") != null ? Long.valueOf(params.get("activityId").toString()) : null;
         String status = (String) params.get("status");
         if (activityId == null) return Result.error("活动ID不能为空");
@@ -20,16 +23,34 @@ public class AdminActivityRegistrationController {
     }
 
     @PostMapping("/registration/approve")
-    public Result<Void> approve(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
-        Long registrationId = params.get("registrationId") != null ? Long.valueOf(params.get("registrationId").toString()) : null;
+    public Result<Void> approve(@RequestBody Map<String, Object> params,
+                                @RequestAttribute("role") String role,
+                                @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("无权限:仅活动管理员可审核报名");
+        }
+        Long registrationId = readRegistrationId(params);
         if (registrationId == null) return Result.error("报名ID不能为空");
         return registrationService.approve(registrationId, userId);
     }
 
     @PostMapping("/registration/reject")
-    public Result<Void> reject(@RequestBody Map<String, Object> params, @RequestAttribute(value = "userId", required = false) Long userId) {
-        Long registrationId = params.get("registrationId") != null ? Long.valueOf(params.get("registrationId").toString()) : null;
+    public Result<Void> reject(@RequestBody Map<String, Object> params,
+                               @RequestAttribute("role") String role,
+                               @RequestAttribute(value = "userId", required = false) Long userId) {
+        if (!"admin".equals(role) && !"activity_admin".equals(role)) {
+            return Result.error("无权限:仅活动管理员可审核报名");
+        }
+        Long registrationId = readRegistrationId(params);
         if (registrationId == null) return Result.error("报名ID不能为空");
         return registrationService.reject(registrationId, userId);
     }
+
+    /** 兼容 registrationId 与 id 两种入参 */
+    private Long readRegistrationId(Map<String, Object> params) {
+        Object regId = params.get("registrationId");
+        if (regId != null) return Long.valueOf(regId.toString());
+        Object id = params.get("id");
+        return id != null ? Long.valueOf(id.toString()) : null;
+    }
 }

+ 43 - 3
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.service.ArticleCategoryService;
 import com.etotem.cfc.service.ArticleCommentService;
 import com.etotem.cfc.service.ArticleService;
 import com.etotem.cfc.service.FileStorageService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
@@ -31,6 +32,9 @@ public class AdminArticleController {
     @Resource
     private ArticleCommentService articleCommentService;
 
+    @Resource
+    private WechatServiceInterface wechatService;
+
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body) {
         String status = (String) body.get("status");
@@ -48,7 +52,18 @@ public class AdminArticleController {
 
     @PostMapping("/create")
     public Result<Map<String, Object>> create(@RequestBody Map<String, Object> body,
-                                  @RequestAttribute("userId") Long adminId) {
+                                   @RequestAttribute("userId") Long adminId) {
+        String title = (String) body.get("title");
+        String content = (String) body.get("content");
+
+        // 文本内容安全检测
+        if (title != null && !title.isEmpty() && !wechatService.checkText(title)) {
+            return Result.error("文章标题包含敏感信息,发布失败");
+        }
+        if (content != null && !content.isEmpty() && !wechatService.checkText(content)) {
+            return Result.error("文章内容包含敏感信息,发布失败");
+        }
+
         Article article = new Article();
         article.setTitle((String) body.get("title"));
         article.setContent((String) body.get("content"));
@@ -106,11 +121,23 @@ public class AdminArticleController {
 
     @PostMapping("/update")
     public Result<String> update(@RequestBody Map<String, Object> body) {
+        String title = (String) body.get("title");
+        String content = (String) body.get("content");
+
+        // 文本内容安全检测
+        if (title != null && !title.isEmpty() && !wechatService.checkText(title)) {
+            return Result.error("文章标题包含敏感信息,更新失败");
+        }
+        if (content != null && !content.isEmpty() && !wechatService.checkText(content)) {
+            return Result.error("文章内容包含敏感信息,更新失败");
+        }
+
         Long articleId = body.get("id") != null ? Long.valueOf(body.get("id").toString()) : null;
         if (articleId != null) {
             Article existing = articleService.getById(articleId);
-            if (existing != null && !"draft".equals(existing.getStatus())) {
-                return Result.error("非草稿状态不可编辑");
+            if (existing != null && !"draft".equals(existing.getStatus())
+                    && !"published".equals(existing.getStatus())) {
+                return Result.error("仅草稿或已上架状态可编辑");
             }
         }
         Article article = new Article();
@@ -254,6 +281,19 @@ public class AdminArticleController {
         if (file.isEmpty()) {
             return Result.error("文件为空");
         }
+
+        // 图片内容安全检测
+        if (file.getContentType() != null && file.getContentType().startsWith("image")) {
+            try {
+                byte[] imageBytes = file.getBytes();
+                if (!wechatService.checkImage(imageBytes)) {
+                    return Result.error("图片内容包含敏感信息,上传失败");
+                }
+            } catch (java.io.IOException e) {
+                return Result.error("图片读取失败: " + e.getMessage());
+            }
+        }
+
         try {
             String url = fileStorageService.store(file, "articles");
             return Result.success(url);

+ 18 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/DimensionWeightController.java

@@ -21,9 +21,18 @@ public class DimensionWeightController {
 
     @PostMapping("/save")
     public Result<Void> save(@RequestBody Map<String, Object> params) {
-        String targetType = params.get("targetType").toString();
-        Long targetId = Long.valueOf(params.get("targetId").toString());
+        Object targetTypeObj = params.get("targetType");
+        Object targetIdObj = params.get("targetId");
+        if (targetTypeObj == null || targetIdObj == null) {
+            return Result.error("targetType 和 targetId 不能为空");
+        }
+        String targetType = targetTypeObj.toString();
+        Long targetId = Long.valueOf(targetIdObj.toString());
+        @SuppressWarnings("unchecked")
         List<Map<String, Object>> weightMaps = (List<Map<String, Object>>) params.get("weights");
+        if (weightMaps == null || weightMaps.isEmpty()) {
+            return Result.error("weights 不能为空");
+        }
         List<DimensionWeight> weights = weightMaps.stream().map(m -> {
             DimensionWeight w = new DimensionWeight();
             w.setDimension(m.get("dimension").toString());
@@ -37,8 +46,13 @@ public class DimensionWeightController {
 
     @PostMapping("/list")
     public Result<List<DimensionWeight>> list(@RequestBody Map<String, Object> params) {
-        String targetType = params.get("targetType").toString();
-        Long targetId = Long.valueOf(params.get("targetId").toString());
+        Object targetTypeObj = params.get("targetType");
+        Object targetIdObj = params.get("targetId");
+        if (targetTypeObj == null || targetIdObj == null) {
+            return Result.error("targetType 和 targetId 不能为空");
+        }
+        String targetType = targetTypeObj.toString();
+        Long targetId = Long.valueOf(targetIdObj.toString());
         List<DimensionWeight> list = dimensionWeightService.getByTarget(targetType, targetId);
         return Result.success(list);
     }

+ 54 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/VirtualGoodsConfigAdminController.java

@@ -0,0 +1,54 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.VirtualGoodsConfig;
+import com.etotem.cfc.service.VirtualGoodsConfigService;
+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.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/virtual-goods")
+public class VirtualGoodsConfigAdminController {
+
+    @Resource
+    private VirtualGoodsConfigService virtualGoodsConfigService;
+
+    @PostMapping("/list")
+    public Result<List<VirtualGoodsConfig>> list(@RequestBody(required = false) Map<String, Object> params,
+                                                 @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        String goodsType = params == null ? null : (String) params.get("goodsType");
+        return Result.success(virtualGoodsConfigService.list(goodsType));
+    }
+
+    @PostMapping("/save")
+    public Result<Boolean> save(@RequestBody VirtualGoodsConfig config,
+                                @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        return Result.success(virtualGoodsConfigService.save(config));
+    }
+
+    @PostMapping("/delete")
+    public Result<Boolean> delete(@RequestBody Map<String, Object> params,
+                                  @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) {
+            return Result.error("无权限");
+        }
+        Long id = params.get("id") == null ? null : Long.valueOf(params.get("id").toString());
+        if (id == null) {
+            return Result.error("参数错误");
+        }
+        return Result.success(virtualGoodsConfigService.delete(id));
+    }
+}

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/guide/PaymentController.java

@@ -6,14 +6,24 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.PackageOrder;
 import com.etotem.cfc.service.PaymentService;
 import com.etotem.cfc.service.WechatService;
+import com.google.zxing.BarcodeFormat;
+import com.google.zxing.EncodeHintType;
+import com.google.zxing.client.j2se.MatrixToImageWriter;
+import com.google.zxing.qrcode.QRCodeWriter;
+import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 
 import org.springframework.web.bind.annotation.*;
 
+import javax.imageio.ImageIO;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.awt.image.BufferedImage;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.util.HashMap;
+import java.util.Hashtable;
 import java.util.Map;
 
 /**
@@ -68,6 +78,12 @@ public class PaymentController {
         String orderNo = (String) params.get("orderNo");
         String description = (String) params.get("description");
         Integer amount = (Integer) params.get("amount");
+        String contactName = (String) params.get("contactName");
+        String contactPhone = (String) params.get("contactPhone");
+        String contactAddress = (String) params.get("contactAddress");
+
+        // 保存联系人信息
+        paymentService.saveContactInfo(orderNo, contactName, contactPhone, contactAddress);
 
         Map<String, Object> result = paymentService.createWechatNativePayOrder(orderNo, description, amount);
         return Result.success(result);
@@ -114,6 +130,21 @@ public class PaymentController {
         return Result.success(result);
     }
 
+    /**
+     * 微信支付v3回调(JSON格式,含签名)
+     * 接收微信支付结果通知,更新订单状态
+     */
+    @Operation(summary = "微信支付v3回调")
+    @PostMapping("/wechat/v3-notify")
+    public String handleV3Notify(@RequestBody String body, HttpServletRequest request) {
+        String signature = request.getHeader("Wechatpay-Signature");
+        Map<String, Object> result = paymentService.handleWechatNotify(body, signature);
+        if (result != null && "SUCCESS".equals(result.get("code"))) {
+            return "{\"code\":\"SUCCESS\"}";
+        }
+        return "{\"code\":\"FAIL\"}";
+    }
+
     /**
      * 处理微信支付回调
      */
@@ -154,4 +185,22 @@ public class PaymentController {
         
         return Result.success(order);
     }
+
+    /**
+     * 生成支付二维码图片
+     */
+    @Operation(summary = "生成支付二维码")
+    @GetMapping("/qrcode")
+    public void generateQrCode(String text, HttpServletResponse response) throws Exception {
+        int size = 280;
+        Hashtable<EncodeHintType, Object> hints = new Hashtable<>();
+        hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
+        hints.put(EncodeHintType.MARGIN, 1);
+        QRCodeWriter writer = new QRCodeWriter();
+        com.google.zxing.common.BitMatrix bitMatrix = writer.encode(text, BarcodeFormat.QR_CODE, size, size, hints);
+        BufferedImage image = MatrixToImageWriter.toBufferedImage(bitMatrix);
+        response.setContentType("image/png");
+        response.setHeader("Cache-Control", "public, max-age=3600");
+        ImageIO.write(image, "png", response.getOutputStream());
+    }
 }

+ 18 - 2
cfc-backend/src/main/java/com/etotem/cfc/dto/ActivityDTO.java

@@ -3,6 +3,8 @@ package com.etotem.cfc.dto;
 import com.etotem.cfc.entity.Activity;
 import lombok.Data;
 
+import java.text.SimpleDateFormat;
+import java.util.Date;
 
 
 @Data
@@ -18,6 +20,14 @@ public class ActivityDTO {
     private String status;
     private String startTime;
     private String endTime;
+    private String registrationDeadline;
+
+    /** 当前用户是否已报名 */
+    private Boolean registered;
+
+    /** 当前用户的报名状态: pending/approved/rejected/cancelled */
+    private String registrationStatus;
+
     private String location;
     private Integer maxParticipants;
     private Integer currentParticipants;
@@ -55,8 +65,9 @@ public class ActivityDTO {
         dto.setDimensionWeights(activity.getDimensionWeights());
         dto.setActivityType(activity.getActivityType());
         dto.setStatus(activity.getStatus());
-        dto.setStartTime(activity.getStartTime() != null ? activity.getStartTime().toString() : null);
-        dto.setEndTime(activity.getEndTime() != null ? activity.getEndTime().toString() : null);
+        dto.setStartTime(activity.getStartTime() != null ? fmt(activity.getStartTime()) : null);
+        dto.setEndTime(activity.getEndTime() != null ? fmt(activity.getEndTime()) : null);
+        dto.setRegistrationDeadline(activity.getRegistrationDeadline() != null ? fmt(activity.getRegistrationDeadline()) : null);
         dto.setLocation(activity.getLocation());
         dto.setMaxParticipants(activity.getMaxParticipants());
         dto.setCurrentParticipants(activity.getCurrentParticipants());
@@ -91,4 +102,9 @@ public class ActivityDTO {
         }
         return basePrice;
     }
+
+    /** 格式化日期为 "yyyy-MM-dd HH:mm",每次调用新建 SimpleDateFormat(非线程共享) */
+    private static String fmt(Date d) {
+        return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(d);
+    }
 }

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/Activity.java

@@ -46,6 +46,9 @@ public class Activity implements Serializable {
 
     private Date endTime;
 
+    /** 报名截止时间 */
+    private Date registrationDeadline;
+
     private String location;
 
     private Integer maxParticipants;

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/PackageOrder.java

@@ -43,6 +43,10 @@ public class PackageOrder implements Serializable {
 
     private String notifyData;     // 支付回调数据
 
+    private String contactName;     // 联系人姓名
+    private String contactPhone;    // 联系人手机号
+    private String contactAddress;  // 收件地址
+
     private Date createdAt;
 
     private Date updatedAt;
@@ -63,6 +67,9 @@ public class PackageOrder implements Serializable {
     public void setTransactionId(String transactionId) { this.transactionId = transactionId; }
     public void setPaidAt(Date paidAt) { this.paidAt = paidAt; }
     public void setNotifyData(String notifyData) { this.notifyData = notifyData; }
+    public void setContactName(String contactName) { this.contactName = contactName; }
+    public void setContactPhone(String contactPhone) { this.contactPhone = contactPhone; }
+    public void setContactAddress(String contactAddress) { this.contactAddress = contactAddress; }
     public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
     public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
 }

+ 44 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/VirtualGoodsConfig.java

@@ -0,0 +1,44 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("virtual_goods_config")
+public class VirtualGoodsConfig {
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /**
+     * 道具类型:ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION
+     */
+    private String goodsType;
+
+    /**
+     * 业务SKU标识
+     */
+    private String bizKey;
+
+    /**
+     * 微信商户后台道具ID
+     */
+    private String productId;
+
+    /**
+     * 道具名称
+     */
+    private String goodsName;
+
+    /**
+     * 状态:1=启用 0=停用
+     */
+    private Integer status;
+
+    private LocalDateTime createTime;
+
+    private LocalDateTime updateTime;
+}

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/VirtualGoodsConfigMapper.java

@@ -0,0 +1,9 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.VirtualGoodsConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface VirtualGoodsConfigMapper extends BaseMapper<VirtualGoodsConfig> {
+}

+ 48 - 6
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityRegistrationService.java

@@ -11,8 +11,11 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
 import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -110,15 +113,19 @@ public class ActivityRegistrationService {
         return Result.success(null);
     }
 
-    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long childId, Integer page, Integer size) {
+    public Result<Map<String, Object>> getMyRegistrations(Long userId, Long childId, Integer page, Integer size, String status) {
         if (page == null || page < 1) page = 1;
         if (size == null || size < 1) size = 10;
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<>();
-        if (userId != null) wrapper.eq(ActivityRegistration::getUserId, userId);
         if (childId != null) {
-            // 手动录入的报名 child_id 为 NULL,也属于该用户,需一并返回
+            // 按孩子查询,同时包含手动录入的报名(child_id 为 NULL 且属于该用户)
             wrapper.and(w -> w.eq(ActivityRegistration::getChildId, childId)
-                .or().isNull(ActivityRegistration::getChildId));
+                .or(inner -> inner.isNull(ActivityRegistration::getChildId).eq(ActivityRegistration::getUserId, userId)));
+        } else if (userId != null) {
+            wrapper.eq(ActivityRegistration::getUserId, userId);
+        }
+        if (status != null && !status.isEmpty()) {
+            wrapper.eq(ActivityRegistration::getStatus, status);
         }
         wrapper.orderByDesc(ActivityRegistration::getRegisteredAt);
         Page<ActivityRegistration> pageResult = registrationMapper.selectPage(new Page<>(page, size), wrapper);
@@ -128,11 +135,20 @@ public class ActivityRegistrationService {
             item.put("childId", reg.getChildId()); item.put("status", reg.getStatus());
             item.put("registrantName", reg.getRegistrantName());
             item.put("registrantPhone", reg.getRegistrantPhone());
-            item.put("registeredAt", reg.getRegisteredAt());
+            item.put("registeredAt", fmtLocal(reg.getRegisteredAt()));
             Activity activity = activityMapper.selectById(reg.getActivityId());
             if (activity != null) {
                 item.put("activityTitle", activity.getTitle());
-                item.put("activityStartTime", activity.getStartTime());
+                item.put("activityDescription", activity.getDescription());
+                item.put("activityCoverImage", activity.getCoverImage());
+                item.put("activityLocation", activity.getLocation());
+                item.put("activityPrice", activity.getPrice());
+                item.put("activityMemberPrice", activity.getMemberPrice());
+                item.put("activityStartTime", fmt(activity.getStartTime()));
+                item.put("activityEndTime", fmt(activity.getEndTime()));
+                item.put("activityRegistrationDeadline", fmt(activity.getRegistrationDeadline()));
+                item.put("activityRequireRegistration", activity.getRequireRegistration());
+                item.put("activityStatus", activity.getStatus());
             }
             return item;
         }).collect(Collectors.toList());
@@ -142,6 +158,32 @@ public class ActivityRegistrationService {
         return Result.success(data);
     }
 
+    /**
+     * 返回当前用户对每个活动的报名状态(activityId -> status),仅 pending/approved 计入"已报名"
+     */
+    public Map<Long, String> getRegisteredStatusMap(Long userId) {
+        Map<Long, String> result = new HashMap<>();
+        if (userId == null) return result;
+        List<ActivityRegistration> list = registrationMapper.selectList(
+            new LambdaQueryWrapper<ActivityRegistration>()
+                .eq(ActivityRegistration::getUserId, userId)
+                .in(ActivityRegistration::getStatus, "pending", "approved"));
+        for (ActivityRegistration reg : list) {
+            result.put(reg.getActivityId(), reg.getStatus());
+        }
+        return result;
+    }
+
+    /** 格式化 Date 为 "yyyy-MM-dd HH:mm",每次调用新建 SimpleDateFormat(非线程共享) */
+    private String fmt(Date d) {
+        return d != null ? new SimpleDateFormat("yyyy-MM-dd HH:mm").format(d) : null;
+    }
+
+    /** 格式化 LocalDateTime 为 "yyyy-MM-dd HH:mm" */
+    private String fmtLocal(LocalDateTime t) {
+        return t != null ? t.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) : null;
+    }
+
     public Result<List<Map<String, Object>>> getRegistrationsByActivity(Long activityId, String status) {
         LambdaQueryWrapper<ActivityRegistration> wrapper = new LambdaQueryWrapper<ActivityRegistration>()
             .eq(ActivityRegistration::getActivityId, activityId).orderByDesc(ActivityRegistration::getRegisteredAt);

+ 39 - 20
cfc-backend/src/main/java/com/etotem/cfc/service/ActivityService.java

@@ -50,32 +50,50 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
     @Resource
     private MembershipService membershipService;
 
-    public Result<Map<String, Object>> list(String dimensionCode, Integer page, Integer size, Long userId) {
-        LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<Activity>()
-                .eq(Activity::getStatus, "published")
-                .orderByAsc(Activity::getStartTime);
-        if (dimensionCode != null && !dimensionCode.isEmpty()) {
-            // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 dimension_code
-            query.and(w -> w
-                .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimensionCode)
-                .or().eq(Activity::getDimensionCode, dimensionCode)
-            );
-        }
-        // 游客过滤:只返回公开活动
-        if (userId == null) {
-            query.and(w -> w.eq(Activity::getVisibility, "public")
-                    .or().eq(Activity::getVisibility, "all")
-                    .or().isNull(Activity::getVisibility));
+    public Result<Map<String, Object>> list(String dimensionCode, String status, boolean mine, Integer page, Integer size, Long userId, String role) {
+        boolean isAdmin = "admin".equals(role) || "activity_admin".equals(role);
+        boolean isGuest = (userId == null || userId <= 0);
+        LambdaQueryWrapper<Activity> query = new LambdaQueryWrapper<>();
+        if (isAdmin && mine) {
+            // 活动管理员:我的活动(我发布的所有活动)
+            query.eq(Activity::getVendorId, userId);
+        } else if (isAdmin && status != null && !status.isEmpty()) {
+            // 活动管理员:按状态过滤(如 ended 历史记录)
+            query.eq(Activity::getStatus, status);
+        } else {
+            query.eq(Activity::getStatus, "published")
+                    .orderByAsc(Activity::getStartTime);
+            if (dimensionCode != null && !dimensionCode.isEmpty()) {
+                // P1: 权重感知查询 — 匹配 dimension_weights > 0 或兼容旧版 dimension_code
+                query.and(w -> w
+                    .apply("COALESCE(JSON_EXTRACT(dimension_weights, CONCAT('$.', {0})), 0) > 0", dimensionCode)
+                    .or().eq(Activity::getDimensionCode, dimensionCode)
+                );
+            }
+            // 游客过滤:只返回公开活动
+            if (isGuest) {
+                query.and(w -> w.eq(Activity::getVisibility, "public")
+                        .or().eq(Activity::getVisibility, "all")
+                        .or().isNull(Activity::getVisibility));
+            }
         }
         Page<Activity> pageResult = this.page(new Page<>(page, size), query);
-        boolean isGuest = (userId == null);
         String memberLevel = null;
         if (!isGuest) {
             try { memberLevel = membershipService.getMemberLevel(userId); } catch (Exception e) { /* ignore */ }
         }
         final String level = memberLevel;
+        // 当前用户报名状态(activityId -> status),用于"已报名"标记
+        final Map<Long, String> regStatusMap = isGuest ? new HashMap<>() : registrationService.getRegisteredStatusMap(userId);
+        List<ActivityDTO> records = pageResult.getRecords().stream().map(a -> {
+            ActivityDTO dto = ActivityDTO.from(a, isGuest, level);
+            String regStatus = regStatusMap.get(a.getId());
+            dto.setRegistered(regStatus != null);
+            dto.setRegistrationStatus(regStatus);
+            return dto;
+        }).collect(Collectors.toList());
         Map<String, Object> data = new HashMap<>();
-        data.put("records", pageResult.getRecords().stream().map(a -> ActivityDTO.from(a, isGuest, level)).collect(Collectors.toList()));
+        data.put("records", records);
         data.put("total", pageResult.getTotal());
         data.put("page", pageResult.getCurrent());
         data.put("size", pageResult.getSize());
@@ -172,16 +190,17 @@ public class ActivityService extends ServiceImpl<ActivityMapper, Activity> {
                 Integer penaltyAmount = activity.getEnergyPenaltyUnregistered();
                 if (penaltyAmount != null && penaltyAmount > 0) {
                     try {
+                        // 未报名签到固定扣"行"维度(action)能量,与活动所属维度无关
                         int deducted = energyService.deductEnergyByCode(
                             childId,
-                            activity.getDimensionCode(),
+                            "action",
                             penaltyAmount,
                             "未报名签到活动《" + activity.getTitle() + "》"
                         );
                         if (deducted >= 0) {
                             result.put("energyPenalty", penaltyAmount);
                             log.info("未报名签到能量扣减: childId={}, activityId={}, penalty={}, dimension={}",
-                                childId, activityId, penaltyAmount, activity.getDimensionCode());
+                                childId, activityId, penaltyAmount, "action");
                         } else {
                             result.put("energyPenaltyFailed", "余额不足");
                             log.warn("未报名签到能量扣减失败(余额不足): childId={}, activityId={}",

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -4,7 +4,9 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.PackageOrder;
+import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.mapper.PackageOrderMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.service.ProductOrderService;
 import com.etotem.cfc.service.api.PaymentServiceInterface;
 import okhttp3.MediaType;
@@ -49,6 +51,9 @@ public class PaymentService implements PaymentServiceInterface {
     @Resource
     private PackageOrderMapper packageOrderMapper;
 
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+
     @Resource
     private CommissionService commissionService;
 
@@ -120,6 +125,51 @@ public class PaymentService implements PaymentServiceInterface {
         return createWechatPrepay(orderNo, description, amount, "");
     }
 
+    public void saveContactInfo(String orderNo, String name, String phone, String address) {
+        try {
+            PackageOrder order = packageOrderMapper.selectOne(
+                new LambdaQueryWrapper<PackageOrder>().eq(PackageOrder::getOrderNo, orderNo));
+            if (order == null) {
+                order = new PackageOrder();
+                order.setOrderNo(orderNo);
+                order.setPrice(0);
+                order.setStatus("pending");
+                order.setCreatedAt(new Date());
+                order.setUpdatedAt(new Date());
+            }
+            order.setContactName(name);
+            order.setContactPhone(phone);
+            order.setContactAddress(address);
+            if (order.getId() == null) {
+                packageOrderMapper.insert(order);
+            } else {
+                packageOrderMapper.updateById(order);
+            }
+            // 同步创建ProductOrder,以便在管理端订单管理中查看
+            try {
+                ProductOrder po = new ProductOrder();
+                po.setOrderNo(orderNo);
+                po.setProductId(0L);
+                po.setProductName("肠道菌群基因检测");
+                po.setProductType("gut_testing");
+                po.setQuantity(1);
+                po.setTotalAmount(1);
+                po.setStatus("paid");
+                po.setPaymentMethod("wechat");
+                po.setAddressSnapshot("{\"name\":\"" + name + "\",\"phone\":\"" + phone + "\",\"address\":\"" + address + "\"}");
+                po.setCreatedAt(new Date());
+                po.setUpdatedAt(new Date());
+                productOrderMapper.insert(po);
+                log.info("已同步创建ProductOrder: orderNo={}", orderNo);
+            } catch (Exception e) {
+                log.warn("同步创建ProductOrder失败: {}", e.getMessage());
+            }
+            log.info("已保存联系人信息: orderNo={}, name={}, phone={}", orderNo, name, phone);
+        } catch (Exception e) {
+            log.warn("保存联系人信息失败: orderNo={}", orderNo, e);
+        }
+    }
+
     @Override
     public Map<String, Object> createWechatNativePayOrder(String orderNo, String description, Integer amount) {
         try {

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/VirtualGoodsConfigService.java

@@ -0,0 +1,49 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.etotem.cfc.entity.VirtualGoodsConfig;
+import com.etotem.cfc.mapper.VirtualGoodsConfigMapper;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class VirtualGoodsConfigService extends ServiceImpl<VirtualGoodsConfigMapper, VirtualGoodsConfig> {
+
+    /**
+     * 查询启用的道具映射(status=1),按 (goodsType, bizKey) 匹配;查不到返回 null
+     */
+    public VirtualGoodsConfig getEnabled(String goodsType, String bizKey) {
+        LambdaQueryWrapper<VirtualGoodsConfig> wrapper = new LambdaQueryWrapper<VirtualGoodsConfig>()
+                .eq(VirtualGoodsConfig::getGoodsType, goodsType)
+                .eq(VirtualGoodsConfig::getBizKey, bizKey)
+                .eq(VirtualGoodsConfig::getStatus, 1)
+                .last("LIMIT 1");
+        return this.getOne(wrapper);
+    }
+
+    /**
+     * 管理端列表:goodsType 为空则查全部,按 id 倒序
+     */
+    public List<VirtualGoodsConfig> list(String goodsType) {
+        LambdaQueryWrapper<VirtualGoodsConfig> wrapper = new LambdaQueryWrapper<VirtualGoodsConfig>()
+                .eq(goodsType != null && !goodsType.isEmpty(), VirtualGoodsConfig::getGoodsType, goodsType)
+                .orderByDesc(VirtualGoodsConfig::getId);
+        return this.list(wrapper);
+    }
+
+    /**
+     * 保存:id 为空则新增,非空则更新(saveOrUpdate 语义)
+     */
+    public boolean save(VirtualGoodsConfig config) {
+        return this.saveOrUpdate(config);
+    }
+
+    /**
+     * 删除
+     */
+    public boolean delete(Long id) {
+        return this.removeById(id);
+    }
+}

+ 147 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java

@@ -11,11 +11,19 @@ import org.springframework.http.MediaType;
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
 import org.springframework.web.client.RestTemplate;
+import org.springframework.util.StreamUtils;
 import com.etotem.cfc.service.api.WechatServiceInterface;
 
 import javax.annotation.PostConstruct;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
+import java.net.HttpURLConnection;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.util.Base64;
 import java.util.HashMap;
@@ -57,6 +65,10 @@ public class WechatService implements WechatServiceInterface {
 
     private final RestTemplate restTemplate = new RestTemplate();
 
+    // ===== access_token 缓存 =====
+    private volatile String cachedAccessToken;
+    private volatile long tokenExpireTime;
+
     /**
      * 判断是否测试模式
      */
@@ -200,9 +212,32 @@ public class WechatService implements WechatServiceInterface {
     }
 
     /**
-     * 获取access_token
+     * 获取access_token(带缓存,TTL 7000秒,提前200秒刷新)
      */
-    private String getAccessToken() {
+    public String getAccessToken() {
+        // 测试模式:每次都重新获取
+        if (testMode) {
+            return doGetAccessToken();
+        }
+
+        long now = System.currentTimeMillis();
+        if (cachedAccessToken != null && now < tokenExpireTime) {
+            return cachedAccessToken;
+        }
+
+        synchronized (this) {
+            if (cachedAccessToken != null && now < tokenExpireTime) {
+                return cachedAccessToken;
+            }
+            String token = doGetAccessToken();
+            // 微信access_token有效期7200秒,提前200秒过期缓存
+            cachedAccessToken = token;
+            tokenExpireTime = now + 7000 * 1000;
+            return token;
+        }
+    }
+
+    private String doGetAccessToken() {
         try {
             String url = String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
                     appid, secret);
@@ -222,6 +257,116 @@ public class WechatService implements WechatServiceInterface {
         }
     }
 
+    /**
+     * 文本内容安全检测(msg_sec_check)
+     * @param content 要检测的文本内容,长度不超过500K字节
+     * @return true=安全, false=包含敏感信息
+     */
+    public boolean checkText(String content) {
+        if (testMode) {
+            log.info("测试模式:跳过文本内容检测");
+            return true;
+        }
+
+        if (content == null || content.trim().isEmpty()) {
+            return true;
+        }
+
+        try {
+            String accessToken = getAccessToken();
+            String url = "https://api.weixin.qq.com/wxa/msg_sec_check?access_token=" + accessToken;
+
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("content", content);
+
+            org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+            headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
+            org.springframework.http.HttpEntity<String> entity = new org.springframework.http.HttpEntity<>(
+                    requestBody.toJSONString(), headers);
+
+            ResponseEntity<String> response = restTemplate.exchange(url,
+                    org.springframework.http.HttpMethod.POST, entity, String.class);
+            JSONObject json = JSON.parseObject(response.getBody());
+            int errcode = json.getInteger("errcode");
+
+            if (errcode == 0) {
+                return true;
+            } else if (errcode == 87014) {
+                log.warn("文本内容安全检测发现敏感信息: errcode={}", errcode);
+                return false;
+            } else {
+                log.warn("文本内容安全检测返回异常: errcode={}, errmsg={}", errcode, json.getString("errmsg"));
+                // API 异常时 fail-open,不阻塞上传
+                return true;
+            }
+        } catch (Exception e) {
+            log.warn("文本内容安全检测调用失败(fail-open): {}", e.getMessage());
+            return true;
+        }
+    }
+
+    /**
+     * 图片内容安全检测(img_sec_check)
+     * @param imageBytes 图片二进制数据,格式支持PNG/JPEG/JPG/GIF
+     * @return true=安全, false=包含敏感内容
+     */
+    public boolean checkImage(byte[] imageBytes) {
+        if (testMode) {
+            log.info("测试模式:跳过图片内容检测");
+            return true;
+        }
+
+        if (imageBytes == null || imageBytes.length == 0) {
+            return true;
+        }
+
+        try {
+            String accessToken = getAccessToken();
+            String url = "https://api.weixin.qq.com/wxa/img_sec_check?access_token=" + accessToken;
+
+            // 使用 HttpURLConnection 发送 multipart/form-data
+            HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+            conn.setRequestMethod("POST");
+            conn.setConnectTimeout(10000);
+            conn.setReadTimeout(30000);
+
+            String boundary = "----WebKitFormBoundary" + UUID.randomUUID().toString().replace("-", "");
+            conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+            conn.setDoOutput(true);
+
+            OutputStream os = conn.getOutputStream();
+            // media field
+            os.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
+            os.write("Content-Disposition: form-data; name=\"media\"; filename=\"check.jpg\"\r\n".getBytes(StandardCharsets.UTF_8));
+            os.write("Content-Type: image/jpeg\r\n\r\n".getBytes(StandardCharsets.UTF_8));
+            os.write(imageBytes);
+            os.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
+            os.flush();
+            os.close();
+
+            InputStream is = conn.getInputStream();
+            byte[] responseBytes = StreamUtils.copyToByteArray(is);
+            is.close();
+            String responseBody = new String(responseBytes, StandardCharsets.UTF_8);
+
+            JSONObject json = JSON.parseObject(responseBody);
+            int errcode = json.getInteger("errcode");
+
+            if (errcode == 0) {
+                return true;
+            } else if (errcode == 87014) {
+                log.warn("图片内容安全检测发现敏感内容: errcode={}", errcode);
+                return false;
+            } else {
+                log.warn("图片内容安全检测返回异常: errcode={}, errmsg={}", errcode, json.getString("errmsg"));
+                return true;
+            }
+        } catch (Exception e) {
+            log.warn("图片内容安全检测调用失败(fail-open): {}", e.getMessage());
+            return true;
+        }
+    }
+
     /**
      * 生成小程序二维码(wxacode)
      * @param scene 场景参数(最大32个可见字符)

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/api/WechatServiceInterface.java

@@ -6,4 +6,7 @@ public interface WechatServiceInterface {
     boolean isTestMode();
     Map<String, String> code2Session(String code);
     String getPhoneNumber(String code);
+    String getAccessToken();
+    boolean checkText(String content);
+    boolean checkImage(byte[] imageBytes);
 }

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

@@ -76,6 +76,8 @@ wechat:
   login-url: https://api.weixin.qq.com/sns/jscode2session
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   test-mode: false  # 测试模式:跳过微信API调用,使用模拟数据
+  appkey: ${WECHAT_APPKEY:}                # 虚拟支付 AppKey(商户后台基本配置获取,现网/沙箱分开)
+  virtual-pay-env: ${WECHAT_VIRTUAL_PAY_ENV:0}   # 0=现网 1=沙箱
   test-phone: "13800138000"  # 测试模式使用的手机号
   alert-template-id: ${WECHAT_ALERT_TEMPLATE_ID:}
   promotion-tier-template-id: ${WECHAT_PROMOTION_TIER_TEMPLATE_ID:}

+ 22 - 3
cfc-backend/src/main/resources/schema.sql

@@ -619,9 +619,9 @@ CREATE TABLE IF NOT EXISTS task_plan_items (
 CREATE TABLE IF NOT EXISTS package_orders (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     order_no VARCHAR(32) UNIQUE NOT NULL,
-    user_id BIGINT NOT NULL COMMENT '购买者用户ID',
-    family_id BIGINT NOT NULL COMMENT '家庭ID',
-    package_id BIGINT NOT NULL COMMENT '套餐ID',
+    user_id BIGINT DEFAULT NULL COMMENT '购买者用户ID',
+    family_id BIGINT DEFAULT NULL COMMENT '家庭ID',
+    package_id BIGINT DEFAULT NULL COMMENT '套餐ID',
     package_name VARCHAR(100) COMMENT '套餐名称',
     price INT NOT NULL COMMENT '支付金额(分)',
     platform_fee INT DEFAULT 0 COMMENT '平台服务费(分)',
@@ -632,6 +632,9 @@ CREATE TABLE IF NOT EXISTS package_orders (
     transaction_id VARCHAR(64) COMMENT '微信支付订单号',
     paid_at DATETIME COMMENT '支付时间',
     notify_data TEXT COMMENT '支付回调数据',
+    contact_name VARCHAR(50) COMMENT '联系人姓名',
+    contact_phone VARCHAR(20) COMMENT '联系人手机号',
+    contact_address VARCHAR(500) COMMENT '收件地址',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_user_id (user_id),
@@ -1737,6 +1740,7 @@ CREATE TABLE IF NOT EXISTS activities (
     status           VARCHAR(20) DEFAULT 'draft' COMMENT '状态: draft/published/ended',
     start_time       DATETIME COMMENT '开始时间',
     end_time         DATETIME COMMENT '结束时间',
+    registration_deadline DATETIME COMMENT '报名截止时间',
     location         VARCHAR(200) COMMENT '活动地点',
     max_participants INT DEFAULT 0 COMMENT '最大参与人数',
     current_participants INT DEFAULT 0 COMMENT '当前参与人数',
@@ -3682,3 +3686,18 @@ CREATE TABLE IF NOT EXISTS recommendation_logs (
 
 -- activities表添加family_scope列(推荐系统-家庭参与去重)
 -- ALTER TABLE activities ADD COLUMN family_scope TINYINT DEFAULT 1 COMMENT '是否面向家庭: 1=家庭成员参与过即排除, 0=仅按个人参与排除';
+
+-- ============================================================
+-- 小程序虚拟支付道具映射
+-- ============================================================
+CREATE TABLE IF NOT EXISTS virtual_goods_config (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    goods_type VARCHAR(32) NOT NULL COMMENT 'ASSESSMENT_PACKAGE / MEMBERSHIP / SUBSCRIPTION',
+    biz_key VARCHAR(64) NOT NULL COMMENT '业务SKU标识: 测评套餐ID / 会员等级code / 订阅档位code',
+    product_id VARCHAR(64) NOT NULL COMMENT '微信商户后台道具ID',
+    goods_name VARCHAR(128) NOT NULL COMMENT '道具名称',
+    status TINYINT NOT NULL DEFAULT 1 COMMENT '1=启用 0=停用',
+    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
+    update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    UNIQUE KEY uk_goods_type_biz (goods_type, biz_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='小程序虚拟支付道具映射';

+ 11 - 11
cfc-backend/src/test/java/com/etotem/cfc/controller/ActivityControllerTest.java

@@ -71,7 +71,7 @@ public class ActivityControllerTest {
         Page<Activity> page = new Page<>(1, 10, 2);
         page.setRecords(records);
 
-        when(activityService.list(isNull(), eq(1), eq(10), isNull()))
+        when(activityService.list(isNull(), isNull(), eq(false), eq(1), eq(10), isNull(), isNull()))
             .thenReturn(new com.etotem.cfc.common.Result<Map<String, Object>>() {{
                 Map<String, Object> data = new HashMap<>();
                 data.put("records", records.stream().map(ActivityDTO::from).collect(Collectors.toList()));
@@ -82,7 +82,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<Map<String, Object>> result = controller.list(params, null);
+        Result<Map<String, Object>> result = controller.list(params, null, null);
         assertEquals(200, result.getCode());
         assertNotNull(result.getData());
     }
@@ -98,7 +98,7 @@ public class ActivityControllerTest {
             mockActivity(3L, "体质检测", "published", "body")
         );
 
-        when(activityService.list(eq("body"), eq(1), eq(10), isNull()))
+        when(activityService.list(eq("body"), isNull(), eq(false), eq(1), eq(10), isNull(), isNull()))
             .thenReturn(new com.etotem.cfc.common.Result<Map<String, Object>>() {{
                 Map<String, Object> data = new HashMap<>();
                 data.put("records", records.stream().map(ActivityDTO::from).collect(Collectors.toList()));
@@ -109,18 +109,18 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<Map<String, Object>> result = controller.list(params, null);
+        Result<Map<String, Object>> result = controller.list(params, null, null);
         assertEquals(200, result.getCode());
     }
 
     @Test
-    public void list_FilterByActivityType() {
+    public void list_FilterByStatus() {
         Map<String, Object> params = new HashMap<>();
-        params.put("activityType", "campaign");
+        params.put("status", "ended");
         params.put("page", 1);
         params.put("size", 10);
 
-        when(activityService.list(isNull(), eq(1), eq(10), isNull()))
+        when(activityService.list(isNull(), eq("ended"), eq(false), eq(1), eq(10), isNull(), isNull()))
             .thenReturn(new com.etotem.cfc.common.Result<Map<String, Object>>() {{
                 Map<String, Object> data = new HashMap<>();
                 data.put("records", Collections.emptyList());
@@ -131,7 +131,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<Map<String, Object>> result = controller.list(params, null);
+        Result<Map<String, Object>> result = controller.list(params, null, null);
         assertEquals(200, result.getCode());
         assertEquals(0, ((List<?>)result.getData().get("records")).size());
     }
@@ -142,12 +142,12 @@ public class ActivityControllerTest {
         params.put("page", 1);
         params.put("size", 10);
 
-        // 登录用户可看到自己的私有活动
+        // 登录用户可看到自己的私有活动(管理员视角:mine=true 时按发布者过滤)
         List<Activity> records = Collections.singletonList(
             mockActivity(5L, "内部活动", "draft", "mind")
         );
 
-        when(activityService.list(isNull(), eq(1), eq(10), eq(99L)))
+        when(activityService.list(isNull(), isNull(), eq(true), eq(1), eq(10), eq(99L), eq("activity_admin")))
             .thenReturn(new com.etotem.cfc.common.Result<Map<String, Object>>() {{
                 Map<String, Object> data = new HashMap<>();
                 data.put("records", records.stream().map(ActivityDTO::from).collect(Collectors.toList()));
@@ -158,7 +158,7 @@ public class ActivityControllerTest {
                 setCode(200);
             }});
 
-        Result<Map<String, Object>> result = controller.list(params, 99L);
+        Result<Map<String, Object>> result = controller.list(params, 99L, "activity_admin");
         assertEquals(200, result.getCode());
         assertEquals(1, ((List<?>)result.getData().get("records")).size());
     }

+ 135 - 531
cfc-frontend/components/ArticleBookshelf.vue

@@ -23,7 +23,7 @@
     <!-- 骨架屏 -->
     <view v-else-if="loading && (!articles || articles.length === 0)" class="bs-shelf-skeleton">
       <view class="bs-skeleton-shelf">
-        <view v-for="n in 5" :key="n" class="bs-skeleton-book" :style="{ height: (160 + n * 20) + 'rpx', opacity: 1 - n * 0.1 }"></view>
+        <view v-for="n in 3" :key="n" class="bs-skeleton-book"></view>
       </view>
     </view>
 
@@ -32,76 +32,71 @@
       <!-- 书架装饰线 -->
       <view class="bs-shelf-top-border"></view>
 
-      <!-- 书籍横向滚动 -->
+      <!-- 书籍横向滚动(每屏3本) -->
       <scroll-view
         scroll-x
         class="bs-books-scroll"
         :scroll-left="scrollLeft"
         scroll-with-animation
         :show-scrollbar="false"
+        @scroll="onScroll"
       >
-        <!-- 左内边距起始 -->
-        <view class="bs-book-spacer"></view>
+        <view class="bs-books-inner">
+          <!-- 左内边距起始 -->
+          <view class="bs-book-spacer"></view>
 
-        <!-- 书籍列表 -->
-        <view
-          v-for="(article, idx) in displayArticles"
-          :key="idx"
-          class="bs-book-wrap"
-          @click="openSpread(idx)"
-        >
-          <!-- 书脊主体 -->
+          <!-- 书籍列表 -->
           <view
-            class="bs-book-spine"
-            :style="{
-              background: getBookColor(article, idx),
-              height: getBookHeight(idx) + 'rpx'
-            }"
+            v-for="(article, idx) in displayArticles"
+            :key="idx"
+            class="bs-book-wrap"
+            @click="$emit('articleClick', article)"
           >
-            <!-- 书脊顶部装饰条 -->
-            <view class="bs-book-top-stripe"></view>
-
-            <!-- 书页纹理(顶部) -->
-            <view class="bs-book-pages-top"></view>
-
-            <!-- 竖排标题 -->
-            <view class="bs-book-title-wrap">
-              <text
-                class="bs-book-title"
-                :style="{ color: getTextColor(article, idx) }"
-              >{{ article.title }}</text>
-            </view>
+            <!-- 竖版封面主体 -->
+            <view
+              class="bs-book-cover"
+              :style="{
+                background: getBookColor(article, idx)
+              }"
+            >
+              <!-- 封面图 -->
+              <image
+                v-if="article.coverImage"
+                class="bs-cover-img"
+                :src="getImageUrl(article.coverImage)"
+                mode="aspectFill"
+              />
 
-            <!-- 书脊底部标签 -->
-            <view class="bs-book-bottom">
-              <text
-                class="bs-book-category"
-                :style="{
-                  background: getTextColor(article, idx),
-                  color: getBookColor(article, idx)
-                }"
-              >{{ getCategoryLabel(article) }}</text>
-            </view>
+              <!-- 无封面图时:渐变底 + 书名 -->
+              <view v-else class="bs-cover-text">
+                <text
+                  class="bs-cover-title"
+                  :style="{ color: getTextColor(article, idx) }"
+                >{{ article.title }}</text>
+              </view>
 
-            <!-- 书页纹理(底部) -->
-            <view class="bs-book-pages-bottom"></view>
+              <!-- 底部分类标签 -->
+              <view class="bs-cover-bottom">
+                <text
+                  class="bs-cover-category"
+                  :style="{
+                    background: getTextColor(article, idx),
+                    color: getBookColor(article, idx)
+                  }"
+                >{{ getCategoryLabel(article) }}</text>
+              </view>
+
+              <!-- 右侧书页切边(3D效果) -->
+              <view class="bs-cover-edge"></view>
+            </view>
 
-            <!-- 右侧书页切边 -->
-            <view class="bs-book-edge"></view>
+            <!-- 封面底部阴影(书架效果) -->
+            <view class="bs-book-shadow"></view>
           </view>
 
-          <!-- 书脊底部阴影(书架效果) -->
-          <view
-            class="bs-book-shadow"
-            :style="{
-              background: 'linear-gradient(180deg, rgba(0,0,0,0.12) 0%, rgba(0,0,0,0) 100%)',
-              width: (getBookHeight(idx) * 0.15) + 'rpx'
-            }"
-          ></view>
+          <!-- 右内边距结尾 -->
+          <view class="bs-book-spacer"></view>
         </view>
-
-        <!-- 右内边距结尾 -->
-        <view class="bs-book-spacer"></view>
       </scroll-view>
 
       <!-- 书架底板装饰 -->
@@ -112,136 +107,22 @@
       </view>
 
       <!-- 翻页指示器 -->
-      <view class="bs-page-dots" v-if="displayArticles.length > 4">
+      <view class="bs-page-dots" v-if="displayArticles.length > 3">
         <view
-          v-for="n in Math.min(5, Math.ceil(displayArticles.length / 4))"
+          v-for="n in dotCount"
           :key="n"
           class="bs-dot"
           :class="{ 'bs-dot-active': isActiveDot(n) }"
         ></view>
       </view>
     </view>
-
-    <!-- 杂志展开页(点击书籍后显示) -->
-    <view
-      class="bs-spread-overlay"
-      v-if="spreadActive"
-      @click="closeSpread"
-    >
-      <view class="bs-spread" @click.stop="">
-
-        <!-- 关闭按钮 -->
-        <view class="bs-spread-close" @click="closeSpread">
-          <text class="bs-spread-close-text">&#x2715;</text>
-        </view>
-
-        <!-- 杂志页眉 -->
-        <view class="bs-spread-header">
-          <view class="bs-spread-header-line"></view>
-          <text class="bs-spread-header-title">&#x1F4DA; 阅读精选</text>
-          <view class="bs-spread-header-line"></view>
-        </view>
-
-        <!-- 双页展开 -->
-        <view class="bs-spread-pages">
-          <!-- 左页 -->
-          <view
-            class="bs-spread-page bs-spread-page-left"
-            v-if="spreadArticles[0]"
-            @click="$emit('articleClick', spreadArticles[0])"
-          >
-            <view class="bs-spread-page-cover" v-if="spreadArticles[0].coverImage">
-              <image
-                class="bs-spread-cover-img"
-                :src="spreadArticles[0].coverImage"
-                mode="aspectFill"
-              />
-              <view
-                class="bs-spread-cover-overlay"
-                :style="{ background: getBookColor(spreadArticles[0], 0) }"
-              ></view>
-            </view>
-            <view class="bs-spread-page-body">
-              <text class="bs-spread-page-category" :style="{ color: getBookColor(spreadArticles[0], 0) }">
-                {{ getCategoryLabel(spreadArticles[0]) }}
-              </text>
-              <text class="bs-spread-page-title">{{ spreadArticles[0].title }}</text>
-              <text class="bs-spread-page-summary" v-if="spreadArticles[0].summary">
-                {{ spreadArticles[0].summary }}
-              </text>
-              <view class="bs-spread-page-footer">
-                <text class="bs-spread-page-author" v-if="spreadArticles[0].author">
-                  {{ spreadArticles[0].author }}
-                </text>
-                <text class="bs-spread-page-date" v-if="spreadArticles[0].date">
-                  {{ spreadArticles[0].date }}
-                </text>
-              </view>
-            </view>
-          </view>
-
-          <!-- 书脊分隔 -->
-          <view class="bs-spread-spine"></view>
-
-          <!-- 右页 -->
-          <view
-            class="bs-spread-page bs-spread-page-right"
-            v-if="spreadArticles[1]"
-            @click="$emit('articleClick', spreadArticles[1])"
-          >
-            <view class="bs-spread-page-cover" v-if="spreadArticles[1].coverImage">
-              <image
-                class="bs-spread-cover-img"
-                :src="spreadArticles[1].coverImage"
-                mode="aspectFill"
-              />
-              <view
-                class="bs-spread-cover-overlay"
-                :style="{ background: getBookColor(spreadArticles[1], 1) }"
-              ></view>
-            </view>
-            <view class="bs-spread-page-body">
-              <text class="bs-spread-page-category" :style="{ color: getBookColor(spreadArticles[1], 1) }">
-                {{ getCategoryLabel(spreadArticles[1]) }}
-              </text>
-              <text class="bs-spread-page-title">{{ spreadArticles[1].title }}</text>
-              <text class="bs-spread-page-summary" v-if="spreadArticles[1].summary">
-                {{ spreadArticles[1].summary }}
-              </text>
-              <view class="bs-spread-page-footer">
-                <text class="bs-spread-page-author" v-if="spreadArticles[1].author">
-                  {{ spreadArticles[1].author }}
-                </text>
-                <text class="bs-spread-page-date" v-if="spreadArticles[1].date">
-                  {{ spreadArticles[1].date }}
-                </text>
-              </view>
-            </view>
-          </view>
-
-          <!-- 无右页时占位 -->
-          <view class="bs-spread-page bs-spread-page-right bs-spread-page-empty" v-else>
-            <view class="bs-spread-empty-inner">
-              <text class="bs-spread-empty-icon">&#x1F4D6;</text>
-              <text class="bs-spread-empty-text">更多好文</text>
-              <text class="bs-spread-empty-text2">即将上架</text>
-            </view>
-          </view>
-        </view>
-
-        <!-- 翻页提示 -->
-        <view class="bs-spread-hint">
-          <text class="bs-spread-hint-text">点击封面阅读全文</text>
-          <text class="bs-spread-hint-text" @click="prevSpreadPair">&#x25C0; 上一页</text>
-          <text class="bs-spread-hint-text" @click="nextSpreadPair">下一页 &#x25B6;</text>
-        </view>
-      </view>
-    </view>
   </view>
 </template>
 
 <script>
 // 五维配色体系(书脊颜色)
+import config from '@/config.js'
+
 var DIMENSION_COLORS = {
   body:    { bg: 'linear-gradient(180deg, #FF8C42 0%, #E8762A 100%)', text: '#FFF5EE' },
   mind:    { bg: 'linear-gradient(180deg, #FF6B9D 0%, #E84A7F 100%)', text: '#FFF0F5' },
@@ -268,12 +149,15 @@ export default {
   },
   data: function() {
     return {
-      spreadActive: false,
-      spreadIndex: 0,
-      scrollLeft: 0
+      scrollLeft: 0,
+      activeDot: 1
     }
   },
   computed: {
+    // 每屏 3 本,指示器数量 = ceil(n/3)
+    dotCount: function() {
+      return Math.max(1, Math.ceil(this.displayArticles.length / 3))
+    },
     displayArticles: function() {
       if (!this.articles || this.articles.length === 0) {
         return [
@@ -285,16 +169,6 @@ export default {
         ]
       }
       return this.articles.slice(0, 12)
-    },
-    spreadArticles: function() {
-      var base = this.spreadIndex * 2
-      return [
-        this.displayArticles[base],
-        this.displayArticles[base + 1]
-      ].filter(function(a) { return a })
-    },
-    maxSpreadIndex: function() {
-      return Math.floor((this.displayArticles.length - 1) / 2)
     }
   },
   methods: {
@@ -324,41 +198,25 @@ export default {
       }
       return FALLBACK_GRADIENTS[idx % FALLBACK_GRADIENTS.length].text
     },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
+    },
     getCategoryLabel: function(article) {
       if (!article) return '推荐'
       if (article.categoryName) return article.categoryName
       if (article.category) return article.category
       return '推荐'
     },
-    getBookHeight: function(idx) {
-      // 书高度错落有致,营造书架感
-      var heights = [220, 200, 240, 210, 230, 195, 225, 205, 235, 215, 228, 198]
-      return heights[idx % heights.length]
-    },
-    openSpread: function(idx) {
-      this.spreadIndex = Math.floor(idx / 2)
-      this.spreadActive = true
-    },
-    closeSpread: function() {
-      this.spreadActive = false
-    },
-    prevSpreadPair: function() {
-      if (this.spreadIndex > 0) {
-        this.spreadIndex--
-      } else {
-        this.spreadIndex = this.maxSpreadIndex
-      }
-    },
-    nextSpreadPair: function() {
-      if (this.spreadIndex < this.maxSpreadIndex) {
-        this.spreadIndex++
-      } else {
-        this.spreadIndex = 0
-      }
+    // 滚动时计算当前指示器位置(每 3 本一组)
+    onScroll: function(e) {
+      var scrollLeft = e.detail.scrollLeft || 0
+      // 每本封面宽 200rpx + 间距 24rpx ≈ 224rpx/本;scrollLeft 为 px,需转 px 后按 3 本一组
+      var groupWidth = uni.upx2px(224) * 3
+      var groupIndex = Math.floor(scrollLeft / groupWidth)
+      this.activeDot = Math.min(groupIndex + 1, this.dotCount)
     },
     isActiveDot: function(dotIndex) {
-      var currentDot = Math.floor(this.spreadIndex / 1) + 1
-      return dotIndex === Math.min(currentDot, Math.ceil(this.displayArticles.length / 4))
+      return dotIndex === this.activeDot
     }
   }
 }
@@ -451,13 +309,14 @@ export default {
 .bs-skeleton-shelf {
   display: flex;
   flex-direction: row;
-  align-items: flex-end;
-  gap: 12rpx;
+  align-items: flex-start;
+  gap: 24rpx;
   padding-top: 16rpx;
 }
 .bs-skeleton-book {
-  width: 56rpx;
-  border-radius: 4rpx 4rpx 0 0;
+  width: 200rpx;
+  height: 280rpx;
+  border-radius: 8rpx;
   background: linear-gradient(180deg, #e8e8e8 0%, #f0f0f0 50%, #e8e8e8 100%);
   background-size: 200% 100%;
   animation: skeleton-wave 1.4s ease-in-out infinite;
@@ -493,112 +352,97 @@ export default {
 .bs-books-scroll {
   white-space: nowrap;
   padding: 20rpx 0 0;
-  display: flex;
+  width: 100%;
+}
+.bs-books-inner {
+  display: inline-flex;
   flex-direction: row;
-  align-items: flex-end;
+  align-items: flex-start;
 }
 .bs-book-spacer {
   width: 28rpx;
   flex-shrink: 0;
 }
 
-/* ---- Single Book ---- */
+/* ---- Single Book (竖版封面) ---- */
 .bs-book-wrap {
   display: flex;
-  flex-direction: row;
-  align-items: flex-end;
+  flex-direction: column;
+  align-items: center;
   flex-shrink: 0;
-  margin-right: 10rpx;
+  margin-right: 24rpx;
   position: relative;
 }
-.bs-book-spine {
-  width: 72rpx;
-  border-radius: 6rpx 6rpx 2rpx 2rpx;
+.bs-book-cover {
+  width: 200rpx;
+  height: 280rpx;
+  border-radius: 8rpx 8rpx 4rpx 4rpx;
   position: relative;
   display: flex;
-  flex-direction: column;
   align-items: center;
+  justify-content: center;
   overflow: hidden;
   box-shadow:
-    4rpx 0 8rpx rgba(0,0,0,0.15),
+    4rpx 0 10rpx rgba(0,0,0,0.18),
     -2rpx 0 4rpx rgba(0,0,0,0.08),
-    0 4rpx 12rpx rgba(0,0,0,0.1);
+    0 6rpx 14rpx rgba(0,0,0,0.12);
   transition: transform 0.18s, box-shadow 0.18s;
 }
-.bs-book-wrap:active .bs-book-spine {
-  transform: translateY(-6rpx) rotate(-2deg);
+.bs-book-wrap:active .bs-book-cover {
+  transform: translateY(-6rpx) scale(1.02);
   box-shadow:
-    6rpx 0 12rpx rgba(0,0,0,0.2),
+    6rpx 0 14rpx rgba(0,0,0,0.25),
     -3rpx 0 6rpx rgba(0,0,0,0.1),
-    0 8rpx 20rpx rgba(0,0,0,0.15);
+    0 10rpx 22rpx rgba(0,0,0,0.18);
 }
 
-/* 书脊顶部金色装饰条 */
-.bs-book-top-stripe {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  height: 8rpx;
-  background: linear-gradient(180deg, rgba(255,255,255,0.4) 0%, transparent 100%);
-}
-
-/* 书页纹理(上) */
-.bs-book-pages-top {
+/* 封面图 */
+.bs-cover-img {
   width: 100%;
-  height: 12rpx;
-  margin-top: 6rpx;
-  background: repeating-linear-gradient(
-    180deg,
-    rgba(255,255,255,0.15) 0px,
-    rgba(255,255,255,0.15) 2rpx,
-    transparent 2rpx,
-    transparent 6rpx
-  );
-  flex-shrink: 0;
+  height: 100%;
 }
 
-/* 书脊标题(竖排) */
-.bs-book-title-wrap {
-  flex: 1;
+/* 无封面图时:渐变底 + 书名 */
+.bs-cover-text {
+  width: 100%;
+  height: 100%;
   display: flex;
   align-items: center;
   justify-content: center;
-  padding: 8rpx 6rpx;
-  overflow: hidden;
+  padding: 20rpx 16rpx;
+  box-sizing: border-box;
 }
-.bs-book-title {
-  font-size: 22rpx;
+.bs-cover-title {
+  font-size: 24rpx;
   font-weight: 700;
-  writing-mode: vertical-rl;
-  text-orientation: mixed;
-  letter-spacing: 3rpx;
-  line-height: 1.4;
-  max-height: 160rpx;
+  line-height: 1.5;
+  letter-spacing: 1rpx;
+  text-align: center;
   overflow: hidden;
   text-overflow: ellipsis;
   display: -webkit-box;
-  -webkit-line-clamp: 1;
-  -webkit-box-orient: horizontal;
-  white-space: nowrap;
+  -webkit-line-clamp: 4;
+  -webkit-box-orient: vertical;
+  word-break: break-all;
 }
 
-/* 书脊底部分类 */
-.bs-book-bottom {
-  width: 100%;
-  padding: 6rpx 4rpx 10rpx;
+/* 底部分类标签 */
+.bs-cover-bottom {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  right: 0;
   display: flex;
   justify-content: center;
-  flex-shrink: 0;
+  padding: 10rpx 0 12rpx;
+  background: linear-gradient(180deg, transparent 0%, rgba(0,0,0,0.25) 100%);
 }
-.bs-book-category {
+.bs-cover-category {
   font-size: 16rpx;
   font-weight: 600;
-  padding: 2rpx 4rpx;
-  border-radius: 3rpx;
-  writing-mode: horizontal-tb;
-  letter-spacing: 0;
-  max-width: 64rpx;
+  padding: 3rpx 8rpx;
+  border-radius: 4rpx;
+  max-width: 160rpx;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
@@ -606,25 +450,11 @@ export default {
   text-align: center;
 }
 
-/* 书页纹理(下) */
-.bs-book-pages-bottom {
-  width: 100%;
-  height: 10rpx;
-  background: repeating-linear-gradient(
-    180deg,
-    rgba(255,255,255,0.12) 0px,
-    rgba(255,255,255,0.12) 1rpx,
-    transparent 1rpx,
-    transparent 4rpx
-  );
-  flex-shrink: 0;
-}
-
 /* 右侧书页切边(3D效果) */
-.bs-book-edge {
+.bs-cover-edge {
   position: absolute;
-  top: 8rpx;
-  bottom: 8rpx;
+  top: 10rpx;
+  bottom: 10rpx;
   right: 0;
   width: 6rpx;
   background: linear-gradient(180deg,
@@ -632,16 +462,16 @@ export default {
     rgba(0,0,0,0.1) 50%,
     rgba(0,0,0,0.2) 100%
   );
-  border-radius: 0 3rpx 3rpx 0;
+  border-radius: 0 4rpx 4rpx 0;
 }
 
-/* 书脊投影 */
+/* 封面底部投影(书架效果) */
 .bs-book-shadow {
-  width: 8rpx;
-  height: 16rpx;
-  margin-left: -2rpx;
-  margin-bottom: 2rpx;
-  border-radius: 0 0 4rpx 4rpx;
+  width: 160rpx;
+  height: 14rpx;
+  margin-top: 8rpx;
+  border-radius: 50%;
+  background: radial-gradient(ellipse at center, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0) 70%);
   flex-shrink: 0;
 }
 
@@ -692,230 +522,4 @@ export default {
   width: 24rpx;
   border-radius: 5rpx;
 }
-
-/* ========================================
-   Magazine Spread Overlay
-   ======================================== */
-.bs-spread-overlay {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background: rgba(0,0,0,0.6);
-  z-index: 999;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  animation: fade-in 0.22s ease-out;
-}
-@keyframes fade-in {
-  from { opacity: 0; }
-  to { opacity: 1; }
-}
-
-.bs-spread {
-  width: 90vw;
-  height: 85vh;
-  background: #FDFAF6;
-  border-radius: 24rpx;
-  position: relative;
-  box-shadow: 0 20rpx 60rpx rgba(0,0,0,0.3);
-  overflow: hidden;
-  animation: slide-up 0.25s ease-out;
-}
-@keyframes slide-up {
-  from { transform: translateY(40rpx); opacity: 0; }
-  to { transform: translateY(0); opacity: 1; }
-}
-
-/* 关闭按钮 */
-.bs-spread-close {
-  position: absolute;
-  top: 20rpx;
-  right: 24rpx;
-  width: 56rpx;
-  height: 56rpx;
-  background: rgba(0,0,0,0.15);
-  border-radius: 50%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 10;
-  transition: background 0.15s;
-}
-.bs-spread-close:active {
-  background: rgba(0,0,0,0.3);
-}
-.bs-spread-close-text {
-  font-size: 28rpx;
-  color: #fff;
-  font-weight: bold;
-}
-
-/* 杂志页眉 */
-.bs-spread-header {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  padding: 32rpx 48rpx 20rpx;
-  gap: 16rpx;
-}
-.bs-spread-header-line {
-  flex: 1;
-  height: 2rpx;
-  background: #C4A882;
-}
-.bs-spread-header-title {
-  font-size: 26rpx;
-  color: #8B7355;
-  font-family: 'Georgia', serif;
-  font-weight: 600;
-  white-space: nowrap;
-}
-
-/* 双页布局 */
-.bs-spread-pages {
-  display: flex;
-  flex-direction: row;
-  height: calc(100% - 140rpx);
-  padding: 0 32rpx 24rpx;
-  gap: 0;
-}
-.bs-spread-spine {
-  width: 12rpx;
-  background: linear-gradient(180deg, #C4A882 0%, #A0896A 50%, #8B7355 100%);
-  flex-shrink: 0;
-  box-shadow: 2rpx 0 8rpx rgba(0,0,0,0.2);
-  border-radius: 2rpx;
-}
-
-.bs-spread-page {
-  flex: 1;
-  background: #FFFFFF;
-  border-radius: 12rpx;
-  overflow: hidden;
-  display: flex;
-  flex-direction: column;
-  box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08);
-  transition: transform 0.15s;
-}
-.bs-spread-page-left {
-  border-radius: 12rpx 0 0 12rpx;
-}
-.bs-spread-page-right {
-  border-radius: 0 12rpx 12rpx 0;
-}
-.bs-spread-page-empty {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-.bs-spread-empty-inner {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-}
-.bs-spread-empty-icon {
-  font-size: 64rpx;
-  margin-bottom: 16rpx;
-  opacity: 0.3;
-}
-.bs-spread-empty-text {
-  font-size: 28rpx;
-  color: #C4A882;
-  font-family: 'Georgia', serif;
-}
-.bs-spread-empty-text2 {
-  font-size: 22rpx;
-  color: #D1D5DB;
-  margin-top: 8rpx;
-}
-
-.bs-spread-page-cover {
-  width: 100%;
-  height: 220rpx;
-  position: relative;
-  overflow: hidden;
-}
-.bs-spread-cover-img {
-  width: 100%;
-  height: 100%;
-}
-.bs-spread-cover-overlay {
-  position: absolute;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  height: 60rpx;
-  opacity: 0.5;
-}
-
-.bs-spread-page-body {
-  flex: 1;
-  padding: 24rpx 28rpx;
-  display: flex;
-  flex-direction: column;
-}
-.bs-spread-page-category {
-  font-size: 20rpx;
-  font-weight: 700;
-  margin-bottom: 10rpx;
-  letter-spacing: 1rpx;
-}
-.bs-spread-page-title {
-  font-size: 30rpx;
-  font-weight: 700;
-  color: #1a1a2e;
-  line-height: 1.45;
-  margin-bottom: 12rpx;
-  display: block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: -webkit-box;
-  -webkit-line-clamp: 3;
-  -webkit-box-orient: vertical;
-}
-.bs-spread-page-summary {
-  font-size: 22rpx;
-  color: #6B7280;
-  line-height: 1.6;
-  flex: 1;
-  display: block;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: -webkit-box;
-  -webkit-line-clamp: 4;
-  -webkit-box-orient: vertical;
-}
-.bs-spread-page-footer {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  margin-top: 16rpx;
-  padding-top: 12rpx;
-  border-top: 1rpx solid #F3F4F6;
-}
-.bs-spread-page-author {
-  font-size: 20rpx;
-  color: #9CA3AF;
-}
-.bs-spread-page-date {
-  font-size: 20rpx;
-  color: #9CA3AF;
-}
-
-/* 翻页提示 */
-.bs-spread-hint {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-  padding: 0 48rpx 24rpx;
-}
-.bs-spread-hint-text {
-  font-size: 22rpx;
-  color: #9CA3AF;
-}
 </style>

+ 34 - 10
cfc-frontend/components/ContentSharePoster.vue

@@ -45,11 +45,33 @@ export default {
       return map[this.dimensionCode] || { main: '#F97316', dark: '#EA580C', light: '#FFF7ED' }
     },
     drawPoster() {
+      var self = this
+      uni.createSelectorQuery().in(this)
+        .select('#contentPosterCanvas')
+        .boundingClientRect(function(rect) {
+          var scaleX = 1
+          var scaleY = 1
+          if (rect && rect.width && rect.height) {
+            scaleX = rect.width / 500
+            scaleY = rect.height / 800
+          }
+          self._renderPoster(scaleX, scaleY)
+        })
+        .exec()
+    },
+
+    _renderPoster(scaleX, scaleY) {
       var ctx = uni.createCanvasContext('contentPosterCanvas', this)
       var w = 500
       var h = 800
       var colors = this.getColors()
 
+      // 画布 CSS 用 rpx,实际像素随设备变化(375px 屏约 310x496),而绘制按 500x800 设计坐标;
+      // 不缩放坐标系时右侧/底部内容(含二维码 y≈552-702)会被裁剪出画布,导致海报显示不全。
+      if (scaleX !== 1 || scaleY !== 1) {
+        ctx.scale(scaleX, scaleY)
+      }
+
       var gradient = ctx.createLinearGradient(0, 0, w * 0.3, h)
       gradient.addColorStop(0, colors.main)
       gradient.addColorStop(0.55, colors.dark)
@@ -246,12 +268,7 @@ export default {
       var lineCount = 0
       for (var i = 0; i < chars.length; i++) {
         var testLine = line + chars[i]
-        var testWidth = 0
-        if (ctx.measureText) {
-          try { testWidth = ctx.measureText(testLine).width } catch (e) { testWidth = testLine.length * 18 }
-        } else {
-          testWidth = testLine.length * 18
-        }
+        var testWidth = this._textWidth(ctx, testLine)
         if (testWidth > maxWidth && line.length > 0) {
           ctx.fillText(line, x, y + lineCount * lineHeight)
           line = chars[i]
@@ -259,10 +276,8 @@ export default {
           if (lineCount >= maxLines - 1) {
             var remaining = chars.slice(i).join('')
             var remainingLine = line + remaining
-            if (ctx.measureText) {
-              while (ctx.measureText(remainingLine + '..').width > maxWidth && remainingLine.length > 2) {
-                remainingLine = remainingLine.substring(0, remainingLine.length - 1)
-              }
+            while (this._textWidth(ctx, remainingLine + '..') > maxWidth && remainingLine.length > 2) {
+              remainingLine = remainingLine.substring(0, remainingLine.length - 1)
             }
             ctx.fillText(remainingLine + '..', x, y + lineCount * lineHeight)
             return
@@ -274,6 +289,13 @@ export default {
       ctx.fillText(line, x, y + lineCount * lineHeight)
     },
 
+    _textWidth(ctx, text) {
+      if (ctx.measureText) {
+        try { return ctx.measureText(text).width } catch (e) { return (text || '').length * 18 }
+      }
+      return (text || '').length * 18
+    },
+
     _drawQrPlaceholder(ctx, x, y, size) {
       ctx.setFillStyle('#ffffff')
       this._roundRect(ctx, x - 10, y - 10, size + 20, size + 20, 8)
@@ -355,6 +377,8 @@ export default {
       var self = this
       uni.canvasToTempFilePath({
         canvasId: 'contentPosterCanvas',
+        destWidth: 500,
+        destHeight: 800,
         success: function(res) {
           uni.saveImageToPhotosAlbum({
             filePath: res.tempFilePath,

+ 6 - 1
cfc-frontend/components/DimensionActivities.vue

@@ -37,7 +37,7 @@
             <!-- 封面图(占满卡片高度) -->
             <image
               class="card-cover-img"
-              :src="act.coverImage || '/static/default-activity.png'"
+              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
               mode="aspectFill"
             />
             <!-- 右侧信息区 -->
@@ -86,6 +86,8 @@
 </template>
 
 <script>
+import config from '@/config.js'
+
 var DIMENSION_MAP = {
   body: { name: '身', color: '#FF8C42', icon: '🌏' },
   mind: { name: '心', color: '#FF6B9D', icon: '🔥' },
@@ -182,6 +184,9 @@ export default {
     formatPriceWithSymbol: function(price) {
       if (price == null) return '免费'
       return '¥' + (Number(price) / 100).toFixed(2)
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 0 - 202
cfc-frontend/components/DimensionProductList.vue

@@ -1,202 +0,0 @@
-<template>
-  <view class="dimension-product-list">
-    <view class="section-header">
-      <text class="section-title">{{ title }}</text>
-    </view>
-    <view class="product-grid" v-if="products.length > 0">
-      <view class="product-card" v-for="product in products" :key="product.id" @click="goProduct(product)">
-        <image class="product-cover" :src="product.coverImage || '/static/default-product.png'" mode="aspectFill"></image>
-        <view class="product-info">
-          <text class="product-name">{{ product.name }}</text>
-          <view class="product-price-row">
-            <text class="product-price">¥{{ (product.price / 100).toFixed(2) }}</text>
-            <text class="product-reason" v-if="product.reason">{{ product.reason }}</text>
-          </view>
-          <view class="match-badge" v-if="product.matchScore">
-            <text class="match-score">{{ product.matchScore }}分</text>
-          </view>
-        </view>
-      </view>
-    </view>
-    <view class="empty-state" v-else-if="!loading">
-      <text class="empty-text">{{ familyId ? '暂无推荐商品' : '登录后查看推荐商品' }}</text>
-    </view>
-    <view class="loading-state" v-if="loading">
-      <text class="loading-text">加载中...</text>
-    </view>
-  </view>
-</template>
-
-<script>
-import { getDimensionProducts, clickRepurchaseReminder } from '../utils/api.js'
-
-export default {
-  props: {
-    dimensionCode: { type: String, required: true },
-    familyId: { type: [Number, String], default: null },
-    memberScores: { type: Object, default: function() { return {} } },
-    title: { type: String, default: '为你推荐' }
-  },
-  data: function() {
-    return {
-      products: [],
-      loading: false
-    }
-  },
-  mounted: function() {
-    // 无 familyId(未登录/未选择孩子)时不请求推荐接口,展示引导文案
-    if (!this.familyId) {
-      this.loading = false
-      return
-    }
-    this.loadProducts()
-  },
-  methods: {
-    loadProducts: function() {
-      var self = this
-      self.loading = true
-      var params = {
-        dimensionCode: self.dimensionCode,
-        familyId: self.familyId,
-        limit: 6
-      }
-      if (self.memberScores && Object.keys(self.memberScores).length > 0) {
-        params.memberScores = self.memberScores
-      }
-      
-      getDimensionProducts(params).then(function(res) {
-        self.loading = false
-        if (res.code === 200 && res.data) {
-          self.products = Array.isArray(res.data) ? res.data : (res.data.records || [])
-        }
-      }).catch(function() {
-        self.loading = false
-      })
-    },
-    goProduct: function(product) {
-      if (!product || !product.id) return
-
-      if (product.source === 'repurchase' && product.id) {
-        clickRepurchaseReminder(product.id).catch(function() {})
-      }
-
-      uni.navigateTo({
-        url: '/pages/discover-detail/product-detail/product-detail?id=' + product.productId + '&from=dimension'
-      })
-    }
-  }
-}
-</script>
-
-<style scoped>
-.dimension-product-list {
-  margin: 20rpx 20rpx;
-}
-
-.section-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 20rpx;
-}
-
-.section-title {
-  font-size: 30rpx;
-  font-weight: bold;
-  color: #333;
-}
-
-.product-grid {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 20rpx;
-}
-
-.product-card {
-  width: calc(50% - 10rpx);
-  background: #fff;
-  border-radius: 16rpx;
-  overflow: hidden;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-}
-
-.product-cover {
-  width: 100%;
-  height: 240rpx;
-  background: #f0f0f0;
-}
-
-.product-info {
-  padding: 16rpx;
-}
-
-.product-name {
-  font-size: 26rpx;
-  color: #333;
-  display: block;
-  margin-bottom: 12rpx;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-}
-
-.product-price-row {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  margin-bottom: 8rpx;
-}
-
-.product-price {
-  font-size: 28rpx;
-  color: #F97316;
-  font-weight: bold;
-}
-
-.product-reason {
-  font-size: 22rpx;
-  color: #999;
-  max-width: 60%;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.match-badge {
-  display: inline-block;
-  background: linear-gradient(135deg, #6366F1, #818CF8);
-  padding: 4rpx 16rpx;
-  border-radius: 20rpx;
-  margin-top: 8rpx;
-}
-
-.match-score {
-  font-size: 22rpx;
-  color: #fff;
-  font-weight: 500;
-}
-
-.empty-state {
-  display: flex;
-  justify-content: center;
-  padding: 60rpx 0;
-}
-
-.empty-text {
-  font-size: 26rpx;
-  color: #999;
-}
-
-.loading-state {
-  display: flex;
-  justify-content: center;
-  padding: 40rpx 0;
-}
-
-.loading-text {
-  font-size: 24rpx;
-  color: #999;
-}
-</style>

+ 122 - 18
cfc-frontend/components/DimensionProducts.vue

@@ -1,28 +1,40 @@
 <template>
   <view class="section">
     <view class="section-header">
-      <text class="section-title">🛍️ 推荐商品</text>
-      <text class="section-more" @click="$emit('moreProducts')">更多 ›</text>
+      <text class="section-title">🛍️ {{ title }}</text>
+      <text class="section-more" v-if="showMore" @click="$emit('moreProducts')">更多 ›</text>
     </view>
-    <view v-if="!displayProducts || displayProducts.length === 0" class="empty-state">
-      <text class="empty-tip">{{ isLoggedIn ? '暂无商品' : '登录后查看更多商品' }}</text>
+    <view v-if="!loading && displayProducts.length === 0" class="empty-state">
+      <text class="empty-tip">{{ emptyText }}</text>
     </view>
-    <view class="product-list" v-if="displayProducts && displayProducts.length > 0">
-      <view class="product-card" v-for="prod in displayProducts" :key="prod.id" @click="$emit('productClick', prod)">
-        <image class="product-img" :src="prod.coverImage || '/static/default-product.png'" mode="aspectFill" />
+    <view class="product-list" v-if="displayProducts.length > 0">
+      <view class="product-card" v-for="prod in displayProducts" :key="prod.id" @click="onProductClick(prod)">
+        <image class="product-img" :src="getImageUrl(prod.coverImage) || '/static/default-product.png'" mode="aspectFill" />
         <text class="product-name line-clamp-2">{{ prod.name }}</text>
         <view class="weight-badges" v-if="prod._badges && prod._badges.length">
           <text class="weight-badge" v-for="badge in prod._badges" :key="badge.name" :style="{ background: badge.color + '20', color: badge.color }">{{ badge.name }}{{ badge.weight }}%</text>
         </view>
-        <text class="product-price" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
-        <text class="product-price" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
-        <text class="product-price" v-else>免费</text>
+        <view class="price-row">
+          <text class="product-price" v-if="prod.priceLabel">{{ prod.priceLabel }}</text>
+          <text class="product-price" v-else-if="prod.price">{{ formatPriceWithSymbol(prod.price) }}</text>
+          <text class="product-price" v-else>免费</text>
+          <text class="product-reason" v-if="prod.reason">{{ prod.reason }}</text>
+        </view>
+        <view class="match-badge" v-if="prod.matchScore">
+          <text class="match-score">{{ prod.matchScore }}分</text>
+        </view>
       </view>
     </view>
+    <view class="loading-state" v-if="loading">
+      <text class="loading-text">加载中...</text>
+    </view>
   </view>
 </template>
 
 <script>
+import { getDimensionProducts, clickRepurchaseReminder } from '../utils/api.js'
+import config from '@/config.js'
+
 var DIMENSION_MAP = {
   body: { name: '身', color: '#FF8C42' },
   mind: { name: '心', color: '#FF6B9D' },
@@ -34,27 +46,82 @@ var DIMENSION_MAP = {
 export default {
   props: {
     dimensionCode: { type: String, default: '' },
+    // 兼容模式:父组件直接传入商品数组(member-detail 等页面)
     products: { type: Array, default: function() { return [] } },
-    isLoggedIn: { type: Boolean, default: false }
+    // 自加载模式:传入 familyId 后组件内部调用 getDimensionProducts
+    familyId: { type: [Number, String], default: null },
+    isLoggedIn: { type: Boolean, default: false },
+    title: { type: String, default: '推荐商品' },
+    showMore: { type: Boolean, default: true }
   },
   data: function() {
     return {
+      selfProducts: [],
+      loading: false
     }
   },
   computed: {
     displayProducts: function() {
+      var list = this.products && this.products.length > 0 ? this.products : this.selfProducts
       var self = this
-      return (this.products || []).map(function(item) {
+      return (list || []).map(function(item) {
         item._badges = self.parseWeights(item.dimensionWeights)
         return item
       })
+    },
+    emptyText: function() {
+      // 有 familyId(已登录且选中孩子)但推荐接口无数据
+      if (this.familyId) {
+        return '暂无推荐商品'
+      }
+      return this.isLoggedIn ? '暂无商品' : '登录后查看更多商品'
+    }
+  },
+  mounted: function() {
+    // 自加载模式:有 familyId 且父组件未传入 products 时才请求推荐接口
+    if (this.familyId && (!this.products || this.products.length === 0)) {
+      this.loadDimensionProducts()
+    }
+  },
+  watch: {
+    familyId: function(val) {
+      // 切换孩子/登录态变化时重新加载
+      if (val && (!this.products || this.products.length === 0)) {
+        this.loadDimensionProducts()
+      }
     }
   },
   methods: {
-formatPriceWithSymbol: function(price) {
-if (price == null) return '免费'
-return '¥' + (Number(price) / 100).toFixed(2)
-},
+    loadDimensionProducts: function() {
+      var self = this
+      self.loading = true
+      var params = {
+        dimensionCode: self.dimensionCode,
+        familyId: self.familyId,
+        limit: 6
+      }
+      getDimensionProducts(params).then(function(res) {
+        self.loading = false
+        if (res.code === 200 && res.data) {
+          self.selfProducts = Array.isArray(res.data) ? res.data : (res.data.records || [])
+        }
+      }).catch(function() {
+        self.loading = false
+        self.selfProducts = []
+      })
+    },
+    onProductClick: function(prod) {
+      if (!prod || !prod.id) return
+      // 复购提醒追踪(推荐商品点击行为上报)
+      if (prod.source === 'repurchase' && prod.id) {
+        clickRepurchaseReminder(prod.id).catch(function() {})
+      }
+      this.$emit('productClick', prod)
+    },
+    formatPriceWithSymbol: function(price) {
+      if (price == null) return '免费'
+      return '¥' + (Number(price) / 100).toFixed(2)
+    },
     parseWeights: function(str) {
       if (!str) return []
       try {
@@ -69,6 +136,9 @@ return '¥' + (Number(price) / 100).toFixed(2)
       } catch (e) {
         return []
       }
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }
@@ -132,12 +202,37 @@ return '¥' + (Number(price) / 100).toFixed(2)
   border-radius: 6rpx;
   font-weight: 500;
 }
+.price-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8rpx 16rpx 16rpx;
+}
 .product-price {
   font-size: 28rpx;
   color: #F97316;
   font-weight: bold;
-  padding: 8rpx 16rpx 16rpx;
-  display: block;
+}
+.product-reason {
+  font-size: 20rpx;
+  color: #999;
+  max-width: 60%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.match-badge {
+  display: inline-block;
+  background: linear-gradient(135deg, #6366F1, #818CF8);
+  padding: 4rpx 16rpx;
+  border-radius: 20rpx;
+  margin: 0 16rpx 16rpx;
+}
+.match-score {
+  font-size: 22rpx;
+  color: #fff;
+  font-weight: 500;
 }
 .line-clamp-2 {
   overflow: hidden;
@@ -155,4 +250,13 @@ return '¥' + (Number(price) / 100).toFixed(2)
   font-size: 24rpx;
   color: #999;
 }
+.loading-state {
+  display: flex;
+  justify-content: center;
+  padding: 40rpx 0;
+}
+.loading-text {
+  font-size: 24rpx;
+  color: #999;
+}
 </style>

+ 7 - 2
cfc-frontend/components/RecommendedFeed.vue

@@ -25,7 +25,7 @@
         >
           <!-- 商品卡片 -->
           <template v-if="item.type === 'product'">
-            <image class="feed-img" :src="item.coverImage || '/static/default-product.png'" mode="aspectFill" />
+            <image class="feed-img" :src="getImageUrl(item.coverImage) || '/static/default-product.png'" mode="aspectFill" />
             <view class="feed-info">
               <view class="feed-tag-row">
                 <text class="feed-type-badge product-badge">🛍️ 商品</text>
@@ -38,7 +38,7 @@
           </template>
           <!-- 活动卡片 -->
           <template v-else-if="item.type === 'activity'">
-            <image class="feed-img" :src="item.coverImage || '/static/default-activity.png'" mode="aspectFill" />
+            <image class="feed-img" :src="getImageUrl(item.coverImage) || '/static/default-activity.png'" mode="aspectFill" />
             <view class="feed-info">
               <view class="feed-tag-row">
                 <text class="feed-type-badge activity-badge">🔥 活动</text>
@@ -60,6 +60,8 @@
 </template>
 
 <script>
+import config from '@/config.js'
+
 export default {
   props: {
     products: { type: Array, default: function() { return [] } },
@@ -124,6 +126,9 @@ export default {
     getStatusClass: function(status) {
       var map = { upcoming: 'status-upcoming', ongoing: 'status-ongoing', ended: 'status-ended' }
       return map[status] || ''
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 0 - 180
cfc-frontend/components/RecommendedSection.vue

@@ -1,180 +0,0 @@
-<template>
-  <view class="recommended-section">
-    <view class="section-header">
-      <text class="section-icon">✨</text>
-      <text class="section-title">为你推荐</text>
-      <text class="section-more" v-if="hasMore && items && items.length > 0" @click="$emit('more')">更多 ›</text>
-    </view>
-    <scroll-view class="scroll-x" scroll-x show-scrollbar="false" v-if="items && items.length > 0">
-      <view class="card-list">
-        <view
-          class="card-item"
-          v-for="(item, idx) in items"
-          :key="idx"
-          @click="onItemClick(item)"
-          hover-class="card-hover"
-        >
-          <image class="card-img" :src="item.coverImage || '/static/default.png'" mode="aspectFill" />
-          <view class="card-body">
-            <text class="card-title line-clamp-2">{{ item.title }}</text>
-            <view class="card-footer">
-              <text class="card-reason line-clamp-1">{{ item.recommendReason }}</text>
-              <text class="card-badge" v-if="item.badge" :class="'badge-' + item.badge">{{ badgeLabel(item.badge) }}</text>
-            </view>
-          </view>
-        </view>
-      </view>
-    </scroll-view>
-    <view class="empty-state" v-else>
-      <text class="empty-text">暂无推荐内容</text>
-    </view>
-  </view>
-</template>
-
-<script>
-export default {
-  props: {
-    items: {
-      type: Array,
-      default: function() { return [] }
-    },
-    hasMore: {
-      type: Boolean,
-      default: false
-    }
-  },
-  methods: {
-    onItemClick(item) {
-      this.$emit('itemClick', item)
-    },
-    badgeLabel(badge) {
-      var map = {
-        hot: '热门',
-        new: '最新',
-        renew: '续费',
-        task: '任务'
-      }
-      return map[badge] || badge
-    }
-  }
-}
-</script>
-
-<style scoped>
-.recommended-section {
-  margin: 20rpx 30rpx;
-}
-.section-header {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  margin-bottom: 16rpx;
-}
-.section-icon {
-  font-size: 28rpx;
-  margin-right: 8rpx;
-}
-.section-title {
-  font-size: 30rpx;
-  font-weight: 700;
-  color: #333;
-  flex: 1;
-}
-.section-more {
-  font-size: 24rpx;
-  color: #999;
-}
-.scroll-x {
-  white-space: nowrap;
-  overflow: hidden;
-}
-.card-list {
-  display: flex;
-  flex-direction: row;
-  gap: 16rpx;
-}
-.card-item {
-  display: flex;
-  flex-direction: column;
-  width: 240rpx;
-  background: #fff;
-  border-radius: 16rpx;
-  overflow: hidden;
-  box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
-  flex-shrink: 0;
-}
-.card-hover {
-  opacity: 0.85;
-}
-.card-img {
-  width: 240rpx;
-  height: 160rpx;
-  background: #f5f5f5;
-}
-.card-body {
-  padding: 12rpx;
-  display: flex;
-  flex-direction: column;
-  gap: 8rpx;
-}
-.card-title {
-  font-size: 26rpx;
-  font-weight: 600;
-  color: #333;
-  line-height: 1.4;
-  height: 72rpx;
-}
-.card-footer {
-  display: flex;
-  flex-direction: row;
-  align-items: center;
-  justify-content: space-between;
-}
-.card-reason {
-  font-size: 20rpx;
-  color: #999;
-  flex: 1;
-  margin-right: 8rpx;
-}
-.card-badge {
-  font-size: 20rpx;
-  padding: 2rpx 10rpx;
-  border-radius: 6rpx;
-  flex-shrink: 0;
-}
-.badge-hot {
-  background: #fff1f0;
-  color: #f56c6c;
-}
-.badge-new {
-  background: #e6f7ff;
-  color: #1890ff;
-}
-.badge-renew {
-  background: #fff7e6;
-  color: #fa8c16;
-}
-.badge-task {
-  background: #f6ffed;
-  color: #52c41a;
-}
-.line-clamp-1 {
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-.line-clamp-2 {
-  display: -webkit-box;
-  -webkit-box-orient: vertical;
-  -webkit-line-clamp: 2;
-  overflow: hidden;
-}
-.empty-state {
-  padding: 40rpx 0;
-  text-align: center;
-}
-.empty-text {
-  font-size: 26rpx;
-  color: #ccc;
-}
-</style>

+ 0 - 1
cfc-frontend/config.js

@@ -52,7 +52,6 @@ export default {
   LANGGRAPH_BASE_URL,
   DIFY_BASE_URL,
 
-  /** 构建 API 完整 URL */
   api(path) {
     return API_BASE_URL + path
   }

+ 0 - 122
cfc-frontend/mixins/recommend-mixin.js

@@ -1,122 +0,0 @@
-/**
- * 推荐系统 Mixin — 为维度页面提供个性化推荐加载与行为追踪
- *
- * 用法:
- *   import recommendMixin from '../../mixins/recommend-mixin'
- *   export default {
- *     mixins: [recommendMixin],
- *     methods: {
- *       loadDimensionData: function() {
- *         this.loadRecommendations('action')  // 传入当前维度代码
- *       }
- *     }
- *   }
- *
- * 模板:
- *   <RecommendedSection
- *     v-if="recommendItems.length > 0"
- *     :items="recommendItems"
- *     :hasMore="recommendHasMore"
- *     @itemClick="onRecommendItemClick"
- *     @more="$emit('more')" />
- */
-import { getRecommendList, trackRecommendAction } from '../utils/api'
-
-var recommendMixin = {
-  data: function() {
-    return {
-      recommendItems: [],
-      recommendHasMore: false,
-      _recommendDimension: null
-    }
-  },
-  methods: {
-    /**
-     * 加载个性化推荐列表
-     * @param {String} dimensionCode 维度代码 body/mind/wisdom/action/wealth
-     * @param {Number} limit 推荐数量
-     */
-    loadRecommendations: function(dimensionCode, limit) {
-      limit = limit || 6
-      this._recommendDimension = dimensionCode
-      var self = this
-      getRecommendList({
-        dimensionCode: dimensionCode,
-        size: limit
-      }).then(function(res) {
-        if (res.code === 200 && res.data && res.data.items) {
-          self.recommendItems = res.data.items || []
-          self.recommendHasMore = res.data.hasMore || false
-        }
-      }).catch(function() {
-        // 静默失败,不阻塞页面
-      })
-    },
-
-    /**
-     * 推荐项点击处理 — 跳转 + 行为追踪
-     */
-    onRecommendItemClick: function(item) {
-      if (!item) return
-      // 追踪点击行为
-      this._trackRecommend(item, 'click')
-      // 根据内容类型跳转
-      this._navigateToRecommend(item)
-    },
-
-    /**
-     * 内部:追踪推荐行为
-     */
-    _trackRecommend: function(item, action) {
-      trackRecommendAction({
-        contentType: item.contentType,
-        contentId: item.id,
-        action: action
-      }).catch(function() {
-        // 静默
-      })
-    },
-
-    /**
-     * 内部:根据内容类型导航
-     */
-    _navigateToRecommend: function(item) {
-      var url = ''
-      switch (item.contentType) {
-        case 'article':
-          url = '/pages/article-center/article-detail/article-detail?id=' + item.id
-          break
-        case 'task':
-          url = '/pages/tasks/task-detail/task-detail?id=' + item.id
-          break
-        case 'product':
-          url = '/pages/shop/product-detail/product-detail?id=' + item.id
-          break
-        case 'activity':
-          url = '/pages/activity/activity-detail/activity-detail?id=' + item.id
-          break
-        default:
-          return
-      }
-      if (url) {
-        uni.navigateTo({ url: url })
-      }
-    }
-  },
-
-  /**
-   * 页面显示时展示推荐内容(曝光追踪)
-   */
-  onShow: function() {
-    if (this.recommendItems && this.recommendItems.length > 0) {
-      var self = this
-      // 只追踪前3条曝光(避免过多请求)
-      var showItems = this.recommendItems.slice(0, 3)
-      showItems.forEach(function(item) {
-        self._trackRecommend(item, 'show')
-      })
-    }
-  }
-}
-
-export default recommendMixin

+ 6 - 0
cfc-frontend/pages.json

@@ -1094,6 +1094,12 @@
           "style": {
             "navigationBarTitleText": "创建活动"
           }
+        },
+        {
+          "path": "registrations",
+          "style": {
+            "navigationBarTitleText": "报名管理"
+          }
         }
       ]
     },

+ 7 - 18
cfc-frontend/pages/action/index.vue

@@ -68,10 +68,13 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <DimensionProductList
+    <DimensionProducts
       dimensionCode="action"
       :familyId="activeChildId"
-      title="为你推荐" />
+      :products="dimensionProducts"
+      title="为你推荐"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
 
     <!-- 推荐阅读 -->
     <ArticleBookshelf
@@ -111,13 +114,6 @@
           </view>
         </view>
 
-        <!-- 推荐 -->
-        <RecommendedSection
-          :items="recommendItems"
-          :hasMore="recommendHasMore"
-          @itemClick="onRecommendItemClick"
-          @more="goMoreRecommend" />
-
         <!-- 圈子详情弹窗 -->
         <CircleDetail
           :visible="circleDetailVisible"
@@ -151,20 +147,17 @@ import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
-import DimensionProductList from '../../components/DimensionProductList.vue'
+import DimensionProducts from '../../components/DimensionProducts.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import CircleCard from '../../components/CircleCard.vue'
 import CircleDetail from '../../components/CircleDetail.vue'
 import FamilyRelationGraph from '../../components/FamilyRelationGraph.vue'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import RecommendedSection from '../../components/RecommendedSection.vue'
-import recommendMixin from '../../mixins/recommend-mixin'
 import { getVisibleSections, getEnergyOverview, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers, getFeaturedArticles, getMyCircles, discoverCircles, joinGeneralCircle, leaveGeneralCircle } from '../../utils/api.js'
 
 export default {
-  mixins: [recommendMixin],
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProductList, ArticleBookshelf, FamilyRelationGraph, WuxingSandbox, FamilyMemberStrip, CircleCard, CircleDetail, RecommendedSection },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, ArticleBookshelf, FamilyRelationGraph, WuxingSandbox, FamilyMemberStrip, CircleCard, CircleDetail },
   data() {
     return {
       showTabTransition: true,
@@ -322,10 +315,6 @@ export default {
       this.loadDimensionActivities()
       this.loadDimensionProducts()
       this.loadCircleData()
-      this.loadRecommendations('action')
-    },
-    goMoreRecommend: function() {
-      uni.navigateTo({ url: '/pages/discover/index' })
     },
     loadEnergyData: function() {
       var self = this

+ 18 - 21
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -38,9 +38,9 @@
             <text class="meta-icon">🕐</text>
             <text class="meta-text">{{ formatDate(activity.startTime) }}{{ activity.endTime ? ' ~ ' + formatDate(activity.endTime) : '' }}</text>
           </view>
-          <view class="meta-item" v-if="activity.endTime && activity.requireRegistration === 1">
+          <view class="meta-item" v-if="activity.requireRegistration === 1 && (activity.registrationDeadline || activity.endTime)">
             <text class="meta-icon">⏰</text>
-            <text class="meta-text">报名截止: {{ formatDate(activity.endTime) }}</text>
+            <text class="meta-text">报名截止: {{ formatDate(activity.registrationDeadline || activity.endTime) }}</text>
           </view>
           <view class="meta-item" v-if="activity.location">
             <text class="meta-icon">📍</text>
@@ -81,7 +81,6 @@
         <view class="action-btns">
           <button class="share-btn" open-type="share" @click="onShareTap">
             <text class="share-btn-icon">📤</text>
-            <text class="share-btn-text">分享</text>
           </button>
           <!-- 报名按钮(仅 requireRegistration=1 时显示) -->
           <button
@@ -124,7 +123,6 @@
             <text v-else-if="checkinDone">已签到 ✓</text>
             <text v-else>签到</text>
           </button>
-          <text v-if="activity.requireCheckinReview === 1 && !checkinDone" class="checkin-review-hint">签到需要管理员审核</text>
         </view>
       </view>
       
@@ -265,6 +263,7 @@ export default {
       checkinDone: false,
       checkinPoints: 0,
       registrationStatus: null,
+      registrationChildId: null,
       registrationLoading: false,
       showPenaltyModal: false,
       penaltyAmount: 0,
@@ -326,20 +325,19 @@ export default {
           self.loadFeedbackStatus()
           // Load registration status if logged in and activity requires registration
           if (self.activity.requireRegistration === 1) {
-            var childId = uni.getStorageSync('currentChildId')
-            if (childId) {
-              getMyActivityRegistrations({ childId: childId, page: 1, size: 50 }).then(function(regRes) {
-                if (regRes && regRes.data && regRes.data.records) {
-                  var records = regRes.data.records
-                  for (var i = 0; i < records.length; i++) {
-                    if (records[i].activityId === self.activity.id) {
-                      self.registrationStatus = records[i].status
-                      break
-                    }
+            // 不依赖全局 currentChildId:后端按 userId 返回全部报名记录,再按活动匹配,并缓存记录上的 childId 供签到回退
+            getMyActivityRegistrations({ page: 1, size: 50 }).then(function(regRes) {
+              if (regRes && regRes.data && regRes.data.records) {
+                var records = regRes.data.records
+                for (var i = 0; i < records.length; i++) {
+                  if (records[i].activityId === self.activity.id) {
+                    self.registrationStatus = records[i].status
+                    self.registrationChildId = records[i].childId || null
+                    break
                   }
                 }
-              }).catch(function() {})
-            }
+              }
+            }).catch(function() {})
           }
         } else {
           self.error = true
@@ -359,7 +357,7 @@ export default {
       var self = this
       if (self.checkinLoading || self.checkinDone) return
 
-      var childId = uni.getStorageSync('currentChildId')
+      var childId = uni.getStorageSync('currentChildId') || self.registrationChildId
       if (!childId) {
         uni.showToast({ title: '请选择孩子身份', icon: 'none' })
         return
@@ -416,7 +414,7 @@ export default {
         content: '确定要取消报名吗?',
         success: function(res) {
           if (res.confirm) {
-            var childId = uni.getStorageSync('currentChildId')
+            var childId = uni.getStorageSync('currentChildId') || self.registrationChildId
             self.registrationLoading = true
             cancelActivityRegistration({ id: self.activity.id, childId: childId }).then(function(res) {
               self.registrationLoading = false
@@ -1026,10 +1024,9 @@ export default {
   border-radius: 40rpx; padding: 20rpx; font-size: 28rpx;
   text-align: center;
 }
-.share-btn { height: 64rpx; line-height: 64rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 32rpx; padding: 0 20rpx; display: flex; align-items: center; border: none; flex-shrink: 0; }
+.share-btn { height: 64rpx; width: 64rpx; background: #f5f5f5; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: none; flex-shrink: 0; }
 .share-btn::after { border: none; }
-.share-btn-icon { font-size: 28rpx; margin-right: 4rpx; }
-.share-btn-text { font-size: 24rpx; }
+.share-btn-icon { font-size: 30rpx; }
 
 /* 报名表单弹窗 */
 .reg-modal-overlay {

+ 35 - 0
cfc-frontend/pages/activity/create.vue

@@ -47,6 +47,17 @@
         </picker>
       </view>
 
+      <!-- 报名截止时间 -->
+      <view class="form-item">
+        <text class="form-label">报名截止时间</text>
+        <picker class="form-picker" mode="date" :value="deadlineDatePart" @change="onDeadlineDateChange">
+          <text class="picker-text">{{ deadlineDatePart || '选择日期(可选)' }}</text>
+        </picker>
+        <picker class="form-picker" mode="time" :value="deadlineTimePart" @change="onDeadlineTimeChange" style="margin-top: 16rpx;">
+          <text class="picker-text">{{ deadlineTimePart || '选择时间(可选)' }}</text>
+        </picker>
+      </view>
+
       <!-- 活动地点 -->
       <view class="form-item">
         <text class="form-label">活动地点</text>
@@ -121,6 +132,7 @@ export default {
         status: 'draft',
         startTime: null,
         endTime: null,
+        registrationDeadline: null,
         location: '',
         maxParticipants: 0,
         price: 0,
@@ -136,6 +148,8 @@ export default {
       timePart: '',
       endDatePart: '',
       endTimePart: '',
+      deadlineDatePart: '',
+      deadlineTimePart: '',
       dimIdx: 0,
       typeIdx: 0,
       dimensionOptions: [
@@ -195,6 +209,12 @@ export default {
             self.endTimePart = self.fmtTime(ed)
             self.form.endTime = ed.toISOString()
           }
+          if (d.registrationDeadline) {
+            var dl = new Date(d.registrationDeadline.replace ? d.registrationDeadline.replace(/-/g, '/') : d.registrationDeadline)
+            self.deadlineDatePart = self.fmtDate(dl)
+            self.deadlineTimePart = self.fmtTime(dl)
+            self.form.registrationDeadline = dl.toISOString()
+          }
           // sync pickers
           for (var i = 0; i < self.dimensionOptions.length; i++) {
             if (self.dimensionOptions[i].key === self.form.dimensionCode) self.dimIdx = i
@@ -229,6 +249,14 @@ export default {
       this.endTimePart = e.detail.value
       this.syncEndTime()
     },
+    onDeadlineDateChange(e) {
+      this.deadlineDatePart = e.detail.value
+      this.syncDeadline()
+    },
+    onDeadlineTimeChange(e) {
+      this.deadlineTimePart = e.detail.value
+      this.syncDeadline()
+    },
     syncStartTime() {
       if (this.datePart && this.timePart) {
         this.form.startTime = this.datePart + 'T' + this.timePart + ':00'
@@ -241,6 +269,13 @@ export default {
         this.form.endTime = null
       }
     },
+    syncDeadline() {
+      if (this.deadlineDatePart && this.deadlineTimePart) {
+        this.form.registrationDeadline = this.deadlineDatePart + 'T' + this.deadlineTimePart + ':00'
+      } else {
+        this.form.registrationDeadline = null
+      }
+    },
     chooseImage() {
       var self = this
       uni.chooseImage({

+ 157 - 79
cfc-frontend/pages/activity/index.vue

@@ -8,12 +8,12 @@
       </view>
     </view>
 
-    <!-- Tab bar (4 tabs) -->
+    <!-- Tab bar (role-aware tabs) -->
     <view class="tab-bar">
       <view
         class="tab-item"
         :class="{ active: currentTab === tab.key }"
-        v-for="tab in tabs"
+        v-for="tab in visibleTabs"
         :key="tab.key"
         @click="onTabChange(tab.key)"
       >
@@ -57,13 +57,15 @@
           >
             <image
               class="card-cover"
-              :src="act.coverImage || '/static/default-activity.png'"
+              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
               mode="aspectFill"
             />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
               <text class="card-meta" v-if="act.registrantName">报名人:{{ act.registrantName }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
+              <text class="card-meta" v-if="act.registrationDeadline">报名截止:{{ formatDateTime(act.registrationDeadline) }}</text>
+              <text class="card-desc" v-if="act.description">{{ briefDesc(act.description) }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act._cls || 'upcoming')">
@@ -131,12 +133,17 @@
           >
             <image
               class="card-cover"
-              :src="act.coverImage || '/static/default-activity.png'"
+              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
               mode="aspectFill"
             />
             <view class="card-info">
-              <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <view class="card-title-row">
+                <text class="card-title">{{ act.title }}</text>
+                <text class="card-reg-badge" v-if="act.registered">已报名</text>
+              </view>
+              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
+              <text class="card-meta" v-if="act.registrationDeadline">报名截止:{{ formatDateTime(act.registrationDeadline) }}</text>
+              <text class="card-desc" v-if="act.description">{{ briefDesc(act.description) }}</text>
               <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
               <view class="card-bottom">
                 <text class="card-status status-published">进行中</text>
@@ -166,23 +173,8 @@
         </view>
       </view>
 
-      <!-- Tab 3: history -->
+      <!-- Tab 3: history (admin only — ended activities management) -->
       <view v-if="currentTab === 'history'">
-        <!-- Filter chips (same as mine) -->
-        <scroll-view class="filter-scroll" scroll-x enable-flex>
-          <view class="filter-chips">
-            <view
-              class="filter-chip"
-              :class="{ active: currentMineFilter === filter.value }"
-              v-for="filter in mineFilters"
-              :key="filter.value"
-              @click="onMineFilterChange(filter.value)"
-            >
-              <text class="filter-label">{{ filter.label }}</text>
-            </view>
-          </view>
-        </scroll-view>
-
         <!-- Activity cards -->
         <view class="card-list" v-if="historyActivities.length > 0">
           <view
@@ -193,17 +185,17 @@
           >
             <image
               class="card-cover"
-              :src="act.coverImage || '/static/default-activity.png'"
+              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
               mode="aspectFill"
             />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
-              <text class="card-meta" v-if="act.location">📍 {{ act.location }}</text>
+              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
+              <text class="card-meta" v-if="act.registrationDeadline">报名截止:{{ formatDateTime(act.registrationDeadline) }}</text>
+              <text class="card-desc" v-if="act.description">{{ briefDesc(act.description) }}</text>
               <view class="card-bottom">
                 <text class="card-status status-ended">已结束</text>
-                <text class="card-price" v-if="act.price > 0">¥{{ formatPrice(act.price) }}</text>
-                <text class="card-price free" v-else>免费</text>
+                <text class="manage-action-small" @click.stop="goRegistrations(act)">报名管理</text>
               </view>
             </view>
           </view>
@@ -212,8 +204,8 @@
         <!-- Empty state for history -->
         <view class="empty-state" v-else-if="!historyLoading">
           <text class="empty-icon">📜</text>
-          <text class="empty-title">暂无历史活动</text>
-          <text class="empty-desc">参加过的活动会显示在这里</text>
+          <text class="empty-title">暂无已结束活动</text>
+          <text class="empty-desc">已结束的活动会显示在这里</text>
         </view>
 
         <!-- Loading state -->
@@ -271,12 +263,12 @@
           >
             <image
               class="card-cover"
-              :src="act.coverImage || '/static/default-activity.png'"
+              :src="getImageUrl(act.coverImage) || '/static/default-activity.png'"
               mode="aspectFill"
             />
             <view class="card-info">
               <text class="card-title">{{ act.title }}</text>
-              <text class="card-meta">{{ act.startTime }}</text>
+              <text class="card-meta">开始时间:{{ formatDateTime(act.startTime) }}</text>
               <view class="card-bottom">
                 <text class="card-status" :class="'status-' + (act.status || 'draft')">
                   {{ statusLabel(act.status) }}
@@ -284,6 +276,7 @@
                 <view class="manage-action-group">
                   <text class="manage-action-small" v-if="act.status === 'draft'" @click.stop="doPublish(act.id)">发布</text>
                   <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="doEnd(act.id)">结束</text>
+                  <text class="manage-action-small" v-if="act.status === 'published'" @click.stop="goRegistrations(act)">报名管理</text>
                 </view>
               </view>
             </view>
@@ -304,29 +297,36 @@
 </template>
 
 <script>
-import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities } from '@/utils/api.js'
+import { getActivityList, getMyActivityRegistrations, activityCheckin, cancelActivityRegistration, adminListMyActivities, adminPublishActivity, adminEndActivity } from '@/utils/api.js'
+import config from '@/config.js'
 
 export default {
   computed: {
     isAdmin: function() {
       var userInfo = uni.getStorageSync('userInfo')
       return userInfo && (userInfo.role === 'admin' || userInfo.role === 'activity_admin' || (userInfo.roles && userInfo.roles.indexOf('activity_admin') >= 0))
+    },
+    visibleTabs: function() {
+      var base = [
+        { key: 'mine', label: '我的活动' },
+        { key: 'discover', label: '发现活动' }
+      ]
+      if (this.isAdmin) {
+        base.push({ key: 'history', label: '历史记录' })
+        base.push({ key: 'manage', label: '管理' })
+      }
+      return base
     }
   },
   data() {
     return {
-      tabs: [
-        { key: 'mine', label: '我的活动' },
-        { key: 'discover', label: '发现活动' },
-        { key: 'history', label: '历史记录' },
-        { key: 'manage', label: '管理' }
-      ],
       currentTab: 'mine',
       mineFilters: [
         { label: '全部', value: '' },
         { label: '待开始', value: 'upcoming' },
         { label: '待审核', value: 'pending' },
-        { label: '已签到', value: 'checked_in' }
+        { label: '已通过', value: 'approved' },
+        { label: '已拒绝', value: 'rejected' }
       ],
       dimensionFilters: [
         { label: '全部', value: '' },
@@ -358,7 +358,12 @@ export default {
   },
   onLoad: function(options) {
     if (options && options.tab) {
-      this.currentTab = options.tab
+      // 非管理员不能停留在 历史记录/管理 tab
+      if ((options.tab === 'history' || options.tab === 'manage') && !this.isAdmin) {
+        this.currentTab = 'mine'
+      } else {
+        this.currentTab = options.tab
+      }
       this.loadData()
     }
   },
@@ -371,6 +376,10 @@ export default {
   },
   methods: {
     onTabChange: function(key) {
+      // 非管理员不能进入 历史记录/管理 tab
+      if ((key === 'history' || key === 'manage') && !this.isAdmin) {
+        return
+      }
       this.currentTab = key
       this.hasMore = true
       if (key === 'mine') {
@@ -399,7 +408,7 @@ export default {
     loadManageData: function() {
       var self = this
       this.manageLoading = true
-      adminListMyActivities({ page: 1, size: 100 }).then(function(res) {
+      adminListMyActivities({ page: 1, size: 100, mine: true }).then(function(res) {
         self.manageLoading = false
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
@@ -448,11 +457,19 @@ export default {
         success: function(res) {
           if (res.confirm) {
             uni.showLoading({ title: '发布中...' })
-            setTimeout(function() {
+            adminPublishActivity(id).then(function(r) {
+              uni.hideLoading()
+              if (r && r.code === 200) {
+                uni.showToast({ title: '发布成功', icon: 'success' })
+              } else {
+                uni.showToast({ title: (r && r.message) || '发布失败', icon: 'none' })
+              }
+              self.loadManageData()
+            }).catch(function() {
               uni.hideLoading()
-              uni.showToast({ title: '发布成功', icon: 'success' })
+              uni.showToast({ title: '网络异常', icon: 'none' })
               self.loadManageData()
-            }, 500)
+            })
           }
         }
       })
@@ -465,11 +482,19 @@ export default {
         success: function(res) {
           if (res.confirm) {
             uni.showLoading({ title: '操作中...' })
-            setTimeout(function() {
+            adminEndActivity(id).then(function(r) {
+              uni.hideLoading()
+              if (r && r.code === 200) {
+                uni.showToast({ title: '操作成功', icon: 'success' })
+              } else {
+                uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+              }
+              self.loadManageData()
+            }).catch(function() {
               uni.hideLoading()
-              uni.showToast({ title: '操作成功', icon: 'success' })
+              uni.showToast({ title: '网络异常', icon: 'none' })
               self.loadManageData()
-            }, 500)
+            })
           }
         }
       })
@@ -532,15 +557,14 @@ export default {
       }
       this.mineLoading = true
       var childId = uni.getStorageSync('currentChildId')
-      if (!childId) {
-        this.mineLoading = false
-        return
-      }
       var params = {
-        childId: childId,
         page: this.minePage,
         size: this.pageSize
       }
+      if (childId) {
+        params.childId = childId
+      }
+      // 仅将后端可识别的状态传给接口;'upcoming' 为前端客户端过滤
       if (this.currentMineFilter && this.currentMineFilter !== 'upcoming') {
         params.status = this.currentMineFilter
       }
@@ -553,8 +577,8 @@ export default {
             var filtered = []
             for (var i = 0; i < records.length; i++) {
               var act = records[i]
-              if (act.startTime) {
-                var startTime = new Date(act.startTime.replace(/-/g, '/'))
+              if (act.activityStartTime) {
+                var startTime = new Date(act.activityStartTime.replace(/-/g, '/'))
                 if (startTime > now && act.status === 'approved') {
                   filtered.push(act)
                 }
@@ -563,6 +587,7 @@ export default {
             records = filtered
           }
           for (var di = 0; di < records.length; di++) {
+            self.normalizeRegistration(records[di])
             self.decorateActivity(records[di])
           }
           if (refresh) {
@@ -612,37 +637,20 @@ export default {
         this.historyActivities = []
       }
       this.historyLoading = true
-      var childId = uni.getStorageSync('currentChildId')
-      if (!childId) {
-        this.historyLoading = false
-        return
-      }
+      // 历史记录 tab:活动管理员查看已结束活动(用于管理)
       var params = {
-        childId: childId,
+        status: 'ended',
         page: this.historyPage,
         size: this.pageSize
       }
-      getMyActivityRegistrations(params).then(function(res) {
+      getActivityList(params).then(function(res) {
         self.historyLoading = false
         if (res && res.code === 200 && res.data) {
           var records = res.data.records || []
-          var now = new Date()
-          var ended = []
-          for (var i = 0; i < records.length; i++) {
-            var act = records[i]
-            if (act.endTime) {
-              var endTime = new Date(act.endTime.replace(/-/g, '/'))
-              if (endTime < now || act.status === 'cancelled' || act.status === 'rejected') {
-                ended.push(act)
-              }
-            } else {
-              ended.push(act)
-            }
-          }
           if (refresh) {
-            self.historyActivities = ended
+            self.historyActivities = records
           } else {
-            self.historyActivities = self.historyActivities.concat(ended)
+            self.historyActivities = self.historyActivities.concat(records)
           }
           self.hasMore = records.length >= self.pageSize
         }
@@ -653,6 +661,9 @@ export default {
     goDetail: function(act) {
       uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id })
     },
+    goRegistrations: function(act) {
+      uni.navigateTo({ url: '/pages/activity/registrations?activityId=' + act.id + '&title=' + encodeURIComponent(act.title || '') })
+    },
     switchToDiscover: function() {
       this.currentTab = 'discover'
       this.loadData()
@@ -660,6 +671,36 @@ export default {
     formatPrice: function(price) {
       return (price / 100).toFixed(2)
     },
+    /** 将报名记录字段规范化为活动卡片字段(后端 my-registrations 返回 activity* 前缀字段) */
+    normalizeRegistration: function(rec) {
+      rec.id = rec.activityId
+      rec.title = rec.activityTitle
+      rec.coverImage = rec.activityCoverImage
+      rec.description = rec.activityDescription
+      rec.location = rec.activityLocation
+      rec.price = rec.activityPrice
+      rec.memberPrice = rec.activityMemberPrice
+      rec.startTime = rec.activityStartTime
+      rec.endTime = rec.activityEndTime
+      rec.registrationDeadline = rec.activityRegistrationDeadline
+      rec.requireRegistration = rec.activityRequireRegistration
+      rec.activityStatus = rec.activityStatus
+      return rec
+    },
+    formatDateTime: function(v) {
+      if (!v) return ''
+      var s = String(v)
+      if (s.indexOf('-') < 0 && s.indexOf('/') < 0) return s
+      var d = new Date(s.replace(/-/g, '/'))
+      if (isNaN(d.getTime())) return s
+      var pad = function(n) { return n < 10 ? '0' + n : '' + n }
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
+    },
+    briefDesc: function(v) {
+      if (!v) return ''
+      var s = String(v).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
+      return s.length > 36 ? s.substring(0, 36) + '...' : s
+    },
     decorateActivity: function(act) {
       if (act.status) {
         act._cls = act.status
@@ -681,7 +722,8 @@ export default {
     },
     onCheckin: function(act) {
       var self = this
-      var childId = uni.getStorageSync('currentChildId')
+      // 优先使用全局当前孩子;为空时回退到报名记录上的 childId(家长自身视角也可签到)
+      var childId = uni.getStorageSync('currentChildId') || act.childId
       if (!childId) {
         uni.showToast({ title: '请先选择孩子身份', icon: 'none' })
         return
@@ -704,7 +746,8 @@ export default {
         content: '确定要取消报名吗?',
         success: function(res) {
           if (res.confirm) {
-            var childId = uni.getStorageSync('currentChildId')
+            // 优先使用全局当前孩子;为空时回退到报名记录上的 childId
+            var childId = uni.getStorageSync('currentChildId') || act.childId
             if (!childId) {
               uni.showToast({ title: '请先选择孩子身份', icon: 'none' })
               return
@@ -722,6 +765,9 @@ export default {
           }
         }
       })
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }
@@ -854,6 +900,38 @@ export default {
   white-space: nowrap;
 }
 
+.card-title-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.card-title-row .card-title {
+  flex: 1;
+  margin-right: 12rpx;
+}
+
+.card-reg-badge {
+  flex-shrink: 0;
+  font-size: 20rpx;
+  color: #F97316;
+  background: #FFF7ED;
+  border: 1rpx solid #F97316;
+  padding: 2rpx 12rpx;
+  border-radius: 20rpx;
+}
+
+.card-desc {
+  font-size: 24rpx;
+  color: #888;
+  margin-top: 8rpx;
+  line-height: 1.5;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  overflow: hidden;
+}
+
 .card-meta {
   font-size: 24rpx;
   color: #999;

+ 390 - 0
cfc-frontend/pages/activity/registrations.vue

@@ -0,0 +1,390 @@
+<template>
+  <view class="container">
+    <!-- 活动标题 -->
+    <view class="header">
+      <text class="header-title">{{ activityTitle || '报名管理' }}</text>
+    </view>
+
+    <!-- 状态筛选 chips -->
+    <scroll-view class="filter-scroll" scroll-x enable-flex>
+      <view class="filter-chips">
+        <view
+          class="filter-chip"
+          :class="{ active: currentFilter === filter.value }"
+          v-for="filter in statusFilters"
+          :key="filter.value"
+          @click="onFilterChange(filter.value)"
+        >
+          <text class="filter-label">{{ filter.label }}</text>
+        </view>
+      </view>
+    </scroll-view>
+
+    <!-- 报名列表 -->
+    <view class="reg-list" v-if="registrations.length > 0">
+      <view
+        class="reg-card"
+        v-for="row in registrations"
+        :key="row.id"
+      >
+        <view class="reg-info">
+          <view class="reg-name-row">
+            <text class="reg-name">{{ row.registrantName || '未填写姓名' }}</text>
+            <text class="reg-tag" :class="'reg-tag-' + row.status">{{ statusText(row.status) }}</text>
+          </view>
+          <text class="reg-meta" v-if="row.registrantPhone">联系电话:{{ row.registrantPhone }}</text>
+          <text class="reg-meta">报名时间:{{ formatDateTime(row.registeredAt) }}</text>
+        </view>
+        <view class="reg-actions" v-if="row.status === 'pending'">
+          <button class="btn-approve" @click="onApprove(row)">通过</button>
+          <button class="btn-reject" @click="onReject(row)">拒绝</button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 空状态 -->
+    <view class="empty-state" v-else-if="!loading">
+      <text class="empty-icon">📋</text>
+      <text class="empty-title">暂无报名记录</text>
+      <text class="empty-desc">当前状态下没有报名申请</text>
+    </view>
+
+    <!-- 加载状态 -->
+    <view class="loading-state" v-if="loading">
+      <view class="loading-spinner"></view>
+      <text class="loading-text">加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import { adminActivityRegistrationList, adminApproveRegistration, adminRejectRegistration, getActivityDetail } from '@/utils/api.js'
+
+export default {
+  data() {
+    return {
+      activityId: null,
+      activityTitle: '',
+      currentFilter: 'all',
+      statusFilters: [
+        { label: '全部', value: 'all' },
+        { label: '待审核', value: 'pending' },
+        { label: '已通过', value: 'approved' },
+        { label: '已拒绝', value: 'rejected' }
+      ],
+      registrations: [],
+      loading: false
+    }
+  },
+  onLoad: function(options) {
+    if (options && options.activityId) {
+      this.activityId = options.activityId
+    }
+    if (options && options.title) {
+      this.activityTitle = decodeURIComponent(options.title)
+    }
+    this.loadDetail()
+    this.loadList()
+  },
+  methods: {
+    loadDetail: function() {
+      var self = this
+      if (!this.activityId || this.activityTitle) return
+      getActivityDetail(this.activityId).then(function(res) {
+        if (res && res.code === 200 && res.data) {
+          self.activityTitle = res.data.title || ''
+        }
+      }).catch(function() {})
+    },
+    loadList: function() {
+      var self = this
+      this.loading = true
+      var params = {
+        activityId: this.activityId,
+        page: 1,
+        size: 100
+      }
+      if (this.currentFilter !== 'all') {
+        params.status = this.currentFilter
+      }
+      adminActivityRegistrationList(params).then(function(res) {
+        self.loading = false
+        if (res && res.code === 200 && res.data) {
+          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
+          self.registrations = list
+        } else {
+          self.registrations = []
+        }
+      }).catch(function() {
+        self.loading = false
+        self.registrations = []
+      })
+    },
+    onFilterChange: function(value) {
+      this.currentFilter = value
+      this.loadList()
+    },
+    onApprove: function(row) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确认通过该报名吗?',
+        success: function(res) {
+          if (res.confirm) {
+            adminApproveRegistration({ registrationId: row.id, activityId: self.activityId }).then(function(r) {
+              if (r && r.code === 200) {
+                uni.showToast({ title: '已通过', icon: 'success' })
+                self.loadList()
+              } else {
+                uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    onReject: function(row) {
+      var self = this
+      uni.showModal({
+        title: '提示',
+        content: '确认拒绝该报名吗?',
+        success: function(res) {
+          if (res.confirm) {
+            adminRejectRegistration({ registrationId: row.id, activityId: self.activityId }).then(function(r) {
+              if (r && r.code === 200) {
+                uni.showToast({ title: '已拒绝', icon: 'success' })
+                self.loadList()
+              } else {
+                uni.showToast({ title: (r && r.message) || '操作失败', icon: 'none' })
+              }
+            }).catch(function() {
+              uni.showToast({ title: '网络异常', icon: 'none' })
+            })
+          }
+        }
+      })
+    },
+    statusText: function(status) {
+      var map = { pending: '待审核', approved: '已通过', rejected: '已拒绝', cancelled: '已取消' }
+      return map[status] || status || '未知'
+    },
+    formatDateTime: function(v) {
+      if (!v) return ''
+      var s = String(v)
+      if (s.indexOf('-') < 0 && s.indexOf('/') < 0) return s
+      var d = new Date(s.replace(/-/g, '/'))
+      if (isNaN(d.getTime())) return s
+      var pad = function(n) { return n < 10 ? '0' + n : '' + n }
+      return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #F5F5F5;
+  display: flex;
+  flex-direction: column;
+}
+
+.header {
+  background: #1A0F00;
+  padding: 20rpx 24rpx;
+  text-align: center;
+}
+
+.header-title {
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: 600;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.filter-scroll {
+  padding: 20rpx 24rpx;
+  background: #fff;
+}
+
+.filter-chips {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+}
+
+.filter-chip {
+  padding: 12rpx 24rpx;
+  border-radius: 16px;
+  background: #fff;
+  border: 1rpx solid #ddd;
+  flex-shrink: 0;
+}
+
+.filter-chip.active {
+  background: #FFF7ED;
+  border-color: #F97316;
+}
+
+.filter-label {
+  font-size: 24rpx;
+  color: #666;
+}
+
+.filter-chip.active .filter-label {
+  color: #F97316;
+  font-weight: 500;
+}
+
+.reg-list {
+  padding: 20rpx 24rpx;
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.reg-card {
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 24rpx;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+
+.reg-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.reg-name-row {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.reg-name {
+  font-size: 30rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.reg-tag {
+  margin-left: 16rpx;
+  font-size: 20rpx;
+  padding: 2rpx 12rpx;
+  border-radius: 6rpx;
+}
+
+.reg-tag-pending {
+  background: #FFF7ED;
+  color: #F59E0B;
+}
+
+.reg-tag-approved {
+  background: #F0FDF4;
+  color: #10B981;
+}
+
+.reg-tag-rejected {
+  background: #F5F5F5;
+  color: #9CA3AF;
+}
+
+.reg-tag-cancelled {
+  background: #F5F5F5;
+  color: #9CA3AF;
+}
+
+.reg-meta {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 8rpx;
+}
+
+.reg-actions {
+  display: flex;
+  flex-direction: row;
+  gap: 16rpx;
+  margin-left: 20rpx;
+  flex-shrink: 0;
+}
+
+.btn-approve {
+  padding: 8rpx 24rpx;
+  font-size: 24rpx;
+  color: #fff;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  border: none;
+  border-radius: 20rpx;
+  line-height: 1.5;
+}
+
+.btn-reject {
+  padding: 8rpx 24rpx;
+  font-size: 24rpx;
+  color: #999;
+  background: #fff;
+  border: 1rpx solid #ddd;
+  border-radius: 20rpx;
+  line-height: 1.5;
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 120rpx 40rpx;
+}
+
+.empty-icon {
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+
+.empty-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 12rpx;
+}
+
+.empty-desc {
+  font-size: 26rpx;
+  color: #999;
+}
+
+.loading-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 80rpx 40rpx;
+}
+
+.loading-spinner {
+  width: 60rpx;
+  height: 60rpx;
+  border: 4rpx solid #FDE68A;
+  border-top-color: #F97316;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+  margin-bottom: 20rpx;
+}
+
+@keyframes spin {
+  to { transform: rotate(360deg); }
+}
+
+.loading-text {
+  font-size: 26rpx;
+  color: #999;
+}
+</style>

+ 5 - 1
cfc-frontend/pages/ai/chat.vue

@@ -102,7 +102,7 @@
         <!-- 推荐卡片 -->
         <view class="rec-list" v-if="msg.recommendations && msg.recommendations.length > 0">
           <view class="rec-card" v-for="(rec, ri) in msg.recommendations" :key="ri" @click="gotoRec(rec)">
-            <image class="rec-cover" v-if="rec.coverImage" :src="rec.coverImage" mode="aspectFill" />
+            <image class="rec-cover" v-if="rec.coverImage" :src="getImageUrl(rec.coverImage)" mode="aspectFill" />
             <view class="rec-info">
               <text class="rec-type-tag">{{ rec.type === 'product' ? '商品' : rec.type === 'activity' ? '活动' : '文章' }}</text>
               <text class="rec-name">{{ rec.name }}</text>
@@ -156,6 +156,7 @@
 import { aiSendMessage, aiGetConversations, aiGetMessages, aiDeleteConversation, aiSendNutritionMessage, aiSendHealthCoachMessage, aiSendHealthButlerMessage } from '../../utils/api.js'
 import BaseEmpty from '../../components/BaseEmpty.vue'
 import AITaskCard from '../../components/AITaskCard.vue'
+import config from '@/config.js'
 
 export default {
   components: { BaseEmpty, AITaskCard },
@@ -419,6 +420,9 @@ export default {
         return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0')
       }
       return (d.getMonth() + 1) + '/' + d.getDate()
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 11 - 6
cfc-frontend/pages/article-center/article-detail.vue

@@ -42,7 +42,6 @@
       <view class="detail-footer">
         <view class="share-btn" @click="generatePoster">
           <text class="share-btn-icon">🖼</text>
-          <text class="share-btn-text">海报</text>
         </view>
       </view>
     </template>
@@ -107,7 +106,7 @@ export default {
     if (options && options.id) { this.articleId = options.id; this.loadDetail(options.id) }
     else { this.error = true; this.errorMsg = '参数错误'; this.loading = false }
   },
-  onShow() { if (this.article && !this.error) this.startReadingTimer() },
+  onShow() { if (this.article && !this.error) this.startNewReadingSession() },
   onHide() { this.pauseReadingTimer() },
   onUnload() { this.pauseReadingTimer() },
   methods: {
@@ -145,12 +144,19 @@ export default {
       return best ? best.code : ''
     },
     onScrollToBottom() {},
+    startNewReadingSession: function() {
+      // 每次进入文章详情页都从 0 开始新会话,时长不延续上一次
+      this.readingSeconds = 0
+      this.lastReportedSeconds = 0
+      this.startReadingTimer()
+    },
     startReadingTimer: function() {
       if (this.isTimerRunning || this.readingCompleted) return
       this.isTimerRunning = true
       var self = this
       this.timerHandle = setInterval(function() { self.readingSeconds++ }, 1000)
-      this.syncHandle = setInterval(function() { self.reportReadingTime() }, 30000)
+      // 每 10 秒自动记录一次该用户已读时长
+      this.syncHandle = setInterval(function() { self.reportReadingTime() }, 10000)
     },
     pauseReadingTimer: function() {
       if (!this.isTimerRunning) return
@@ -268,10 +274,9 @@ export default {
 .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
 
 .detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; gap: 16rpx; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
-.share-btn { height: 72rpx; line-height: 72rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 36rpx; padding: 0 24rpx; display: flex; align-items: center; border: none; }
+.share-btn { height: 72rpx; width: 72rpx; background: #f5f5f5; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: none; }
 .share-btn::after { border: none; }
-.share-btn-icon { font-size: 28rpx; margin-right: 6rpx; }
-.share-btn-text { font-size: 24rpx; }
+.share-btn-icon { font-size: 32rpx; }
 
 /* 答题弹窗 */
 .quiz-mask, .result-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: center; justify-content: center; }

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

@@ -83,7 +83,7 @@
           <view class="ac-card-media">
             <image
               class="ac-card-cover"
-              :src="item.coverImage || '/static/default-article.png'"
+              :src="getImageUrl(item.coverImage) || '/static/default-article.png'"
               mode="aspectFill"
             />
             <view class="ac-card-dimension-tags" v-if="item.relatedDimensions">
@@ -131,6 +131,7 @@
 
 <script>
 import { getArticleList, getFeaturedArticles } from '@/utils/api.js'
+import config from '@/config.js'
 export default {
   data() {
     return {
@@ -286,6 +287,9 @@ export default {
     },
     onBack() {
       uni.navigateBack()
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/article-center/my-posts.vue

@@ -16,7 +16,7 @@
           <image
             v-if="item.coverImage"
             class="post-cover"
-            :src="item.coverImage"
+            :src="getImageUrl(item.coverImage)"
             mode="aspectFill"
           />
           <view class="post-body">
@@ -48,6 +48,7 @@
 
 <script>
 import { getMyPosts } from '@/utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -117,6 +118,9 @@ export default {
       if (item.auditStatus === 'pending') return 'status-pending'
       if (item.auditStatus === 'rejected') return 'status-rejected'
       return 'status-draft'
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/body-detail/dimension-detail.vue

@@ -59,7 +59,7 @@
           :key="p.id"
           @click="goProduct(p)"
         >
-          <image class="dd-prod-img" :src="p.coverImage || '/static/default-product.png'" mode="aspectFill"></image>
+          <image class="dd-prod-img" :src="getImageUrl(p.coverImage) || '/static/default-product.png'" mode="aspectFill"></image>
           <view class="dd-prod-info">
             <text class="dd-prod-name">{{ p.name }}</text>
             <text class="dd-prod-price">¥{{ p.price }}</text>
@@ -78,6 +78,7 @@
 
 <script>
 import { getDimensionHistory, getDimensionOverview, getProductsByDomain } from '@/utils/api'
+import config from '@/config.js'
 
 var SUGGESTIONS = {
   growth: [
@@ -326,6 +327,9 @@ export default {
       if (product && product.id) {
         uni.navigateTo({ url: '/pages/shop/detail?id=' + product.id })
       }
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 13 - 17
cfc-frontend/pages/body/index.vue

@@ -107,10 +107,13 @@
       @moreActivities="goMoreActivities" />
 
     <!-- 商品推荐 -->
-    <DimensionProductList
+    <DimensionProducts
       dimensionCode="body"
       :familyId="activeChildId"
-      title="为你推荐" />
+      :products="dimensionProducts"
+      title="为你推荐"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
 
     <!-- 推荐阅读 -->
     <ArticleBookshelf
@@ -119,13 +122,6 @@
       @articleClick="goArticleDetail"
       @moreArticles="goMoreArticles" />
 
-    <!-- 个性化推荐 -->
-    <RecommendedSection
-      :items="recommendItems"
-      :hasMore="recommendHasMore"
-      @itemClick="onRecommendItemClick"
-      @more="goMoreRecommend" />
-
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
     <AIFloatingAvatar />
@@ -139,18 +135,15 @@ import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
-import DimensionProductList from '../../components/DimensionProductList.vue'
+import DimensionProducts from '../../components/DimensionProducts.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import RadarChart from '../../components/RadarChart.vue'
 import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import RecommendedSection from '../../components/RecommendedSection.vue'
-import recommendMixin from '../../mixins/recommend-mixin'
 import { getVisibleSections, getEnergyOverview, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getVisibleFamilyMembers, getDimensionOverview, getFeaturedArticles } from '../../utils/api.js'
 
 export default {
-  mixins: [recommendMixin],
-  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProductList, ArticleBookshelf, RadarChart, AIFloatingAvatar, FamilyMemberStrip, RecommendedSection },
+  components: { TabTransition, PageBanner, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, ArticleBookshelf, RadarChart, AIFloatingAvatar, FamilyMemberStrip },
   data() {
     return {
       isLoggedIn: false,
@@ -300,10 +293,13 @@ export default {
       this.loadDimensionProducts()
       this.loadSandboxData()
       this.loadHealthDimensions()
-      this.loadRecommendations('body')
     },
-    goMoreRecommend: function() {
-      uni.navigateTo({ url: '/pages/discover/index' })
+    goProductDetail: function(prod) {
+      if (!prod || !prod.id) return
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + prod.id })
+    },
+    goMoreProducts: function() {
+      uni.navigateTo({ url: '/pages/shop/index' })
     },
     loadHealthDimensions: function() {
       var memberId = this.activeChildId || (this.userInfo && this.userInfo.userId)

+ 5 - 1
cfc-frontend/pages/dan-assessment/report-upload.vue

@@ -39,7 +39,7 @@
             :key="item.id"
             @click="goToProduct(item.id)"
           >
-            <image class="purchase-cover" :src="item.coverImage || '/static/default-product.png'" mode="aspectFill" />
+            <image class="purchase-cover" :src="getImageUrl(item.coverImage) || '/static/default-product.png'" mode="aspectFill" />
             <text class="purchase-name">{{ item.name }}</text>
             <text class="purchase-price">{{ formatPriceWithSymbol(item.price) }}</text>
             <view class="purchase-btn">
@@ -141,6 +141,7 @@
 
 <script>
 import { danParsePreview, danConfirmReport, productList } from '../../utils/api.js'
+import config from '@/config.js'
 
 var REPORT_DOMAIN_MAP = {
   mind: 'mind',
@@ -279,6 +280,9 @@ export default {
     formatPriceWithSymbol: function(cents) {
       if (cents === null || cents === undefined) return '¥0'
       return '¥' + (cents / 100).toFixed(2)
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 7 - 6
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -6,7 +6,7 @@
     <scroll-view v-else-if="product.id" scroll-y class="content">
       <image
         class="cover"
-        :src="product.coverImage || '/static/default-product.png'"
+        :src="coverImageUrl || '/static/default-product.png'"
         mode="widthFix"
         @error="onImageError"
       />
@@ -46,7 +46,7 @@
         </view>
         <view class="action-btns">
           <view class="action-extra">
-            <button class="btn-share" @click="onShareProduct">分享</button>
+            <button class="btn-share" @click="onShareProduct">🖼</button>
           </view>
           <view class="action-extra">
             <button class="btn-cart" @click="onAddToCart">加入购物车</button>
@@ -425,14 +425,15 @@ export default {
 }
 .btn-share {
   background: #fff;
-  color: #666;
   border: 2rpx solid #ddd;
-  font-size: 26rpx;
-  padding: 0 24rpx;
+  font-size: 30rpx;
+  padding: 0;
   height: 64rpx;
+  width: 64rpx;
   line-height: 64rpx;
-  border-radius: 32rpx;
+  border-radius: 50%;
   margin-right: 16rpx;
+  text-align: center;
 }
 .btn-share::after {
   border: none;

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

@@ -37,7 +37,7 @@
           class="product-card"
           @click="goDetail(item.id)"
         >
-          <image class="product-cover" :src="item.coverImage || '/static/default-product.png'" mode="aspectFill" />
+          <image class="product-cover" :src="getImageUrl(item.coverImage) || '/static/default-product.png'" mode="aspectFill" />
           <view class="product-info">
             <text class="product-name">{{ item.name }}</text>
             <view class="product-price-row">
@@ -127,6 +127,7 @@
 import PageBanner from '../../components/PageBanner.vue'
 import { productList, getFeaturedArticles, getMyMembership } from '@/utils/api.js'
 import WuxingSandbox from '../../components/wuxing-sandbox.vue'
+import config from '@/config.js'
 export default {
   components: { PageBanner, WuxingSandbox },
   data() {
@@ -218,6 +219,9 @@ export default {
     goArticleDetail(id) {
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
     },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
+    },
     goKnowledgeCenter() {
       uni.navigateTo({ url: '/pages/mind-extra/articles' })
     }

+ 6 - 2
cfc-frontend/pages/guide/activities/detail.vue

@@ -45,7 +45,7 @@
           <text class="form-label">封面图片URL</text>
           <input class="form-input" v-model="form.coverImage" placeholder="输入图片URL" />
           <view class="cover-preview" v-if="form.coverImage">
-            <image class="preview-img" :src="form.coverImage" mode="aspectFill" @error="onImgError" />
+            <image class="preview-img" :src="getImageUrl(form.coverImage)" mode="aspectFill" @error="onImgError" />
           </view>
         </view>
 
@@ -68,7 +68,7 @@
     <!-- 只读模式(已发布/已取消/已完成) -->
     <view class="detail-section" v-else>
       <view class="detail-cover" v-if="form.coverImage">
-        <image class="cover-img" :src="form.coverImage" mode="aspectFill" />
+        <image class="cover-img" :src="getImageUrl(form.coverImage)" mode="aspectFill" />
       </view>
       <view class="detail-cover placeholder" v-else>
         <text class="placeholder-icon">{{ categoryIcon(form.category) }}</text>
@@ -125,6 +125,7 @@
 
 <script>
 import { getGuideActivityDetail, createActivity, updateActivity, publishActivity } from '../../../utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -314,6 +315,9 @@ export default {
       var h = ('0' + d.getHours()).slice(-2)
       var mi = ('0' + d.getMinutes()).slice(-2)
       return y + '-' + m + '-' + day + ' ' + h + ':' + mi
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/guide/activities/index.vue

@@ -24,7 +24,7 @@
       <view class="activity-card" v-for="act in activities" :key="act.id"
         @click="goToDetail(act.id)">
         <view class="act-cover" v-if="act.coverImage">
-          <image class="cover-img" :src="act.coverImage" mode="aspectFill" />
+          <image class="cover-img" :src="getImageUrl(act.coverImage)" mode="aspectFill" />
         </view>
         <view class="act-cover placeholder" v-else>
           <text class="cover-placeholder-text">{{ categoryIcon(act.category) }}</text>
@@ -83,6 +83,7 @@
 
 <script>
 import { getActivityList, publishActivity, cancelActivity, deleteActivity } from '../../../utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -204,6 +205,9 @@ export default {
       var h = ('0' + d.getHours()).slice(-2)
       var mi = ('0' + d.getMinutes()).slice(-2)
       return y + '-' + m + '-' + day + ' ' + h + ':' + mi
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/guide/packages/share.vue

@@ -2,7 +2,7 @@
   <view class="container">
     <!-- 任务模板信息卡片 -->
     <view class="package-card">
-      <image class="cover" :src="pkg.coverImage || '/static/default-cover.png'" mode="aspectFill" />
+      <image class="cover" :src="getImageUrl(pkg.coverImage) || '/static/default-cover.png'" mode="aspectFill" />
       <view class="package-info">
         <text class="name">{{ pkg.name }}</text>
         <text class="desc">{{ pkg.description }}</text>
@@ -50,6 +50,7 @@
 
 <script>
 import { getPackage } from '@/utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -153,6 +154,9 @@ export default {
     },
     closePoster() {
       this.$refs.posterPopup.close()
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/health/report-upload.vue

@@ -116,7 +116,7 @@
             :key="item.id"
             @click="goToProduct(item.id)"
           >
-            <image class="purchase-cover" :src="item.coverImage || '/static/default-product.png'" mode="aspectFill" />
+            <image class="purchase-cover" :src="getImageUrl(item.coverImage) || '/static/default-product.png'" mode="aspectFill" />
             <text class="purchase-name">{{ item.name }}</text>
             <text class="purchase-price">{{ formatPriceWithSymbol(item.price) }}</text>
             <view class="purchase-btn">
@@ -170,6 +170,7 @@
 
 <script>
 import { productList, getFamilyMembers, addFamilyMember, getPublicConfig } from '../../utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -579,6 +580,9 @@ export default {
       }
 
       uni.navigateTo({ url: '/pages/health/report-confirm?' + params })
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 7 - 18
cfc-frontend/pages/index-home/index.vue

@@ -146,13 +146,6 @@
       </view>
     </view>
 
-    <!-- 个性化推荐 -->
-    <RecommendedSection
-      :items="recommendItems"
-      :hasMore="recommendHasMore"
-      @itemClick="onRecommendItemClick"
-      @more="goMoreRecommend" />
-
     <!-- 圈子详情弹窗 -->
     <CircleDetail
       :visible="circleDetailVisible"
@@ -217,7 +210,7 @@
           class="product-card"
           @click="handleLogin()"
         >
-          <image class="product-cover" :src="item.coverImage || '/static/default-product.png'" mode="aspectFill" />
+          <image class="product-cover" :src="getImageUrl(item.coverImage) || '/static/default-product.png'" mode="aspectFill" />
           <view class="product-info">
             <text class="product-name">{{ item.name }}</text>
             <view class="product-price-row">
@@ -240,7 +233,7 @@
       </view>
       <view class="activity-list" v-else>
         <view class="activity-card" v-for="act in dimensionActivities" :key="act.id" @click="goActivityDetail(act)">
-          <image class="activity-cover" :src="act.coverImage || '/static/default-activity.png'" mode="aspectFill" />
+          <image class="activity-cover" :src="getImageUrl(act.coverImage) || '/static/default-activity.png'" mode="aspectFill" />
           <view class="activity-info">
             <text class="activity-name line-clamp-1">{{ act.title }}</text>
             <view class="activity-meta">
@@ -359,9 +352,8 @@ import CircleCard from '../../components/CircleCard.vue'
 import CircleDetail from '../../components/CircleDetail.vue'
 import LoginGuideCard from '../../components/LoginGuideCard.vue'
 import FamilyChallengeCard from '../../components/FamilyChallengeCard.vue'
-import RecommendedSection from '../../components/RecommendedSection.vue'
-import recommendMixin from '../../mixins/recommend-mixin'
 import { acceptParentInvite, productList, getActivityList, getFeaturedArticles, getFamilyEnergySandbox, getVisibleFamilyMembers, getTodayTasksByCategory, getChildren, getEnergyOverview, getProductsByDomain, getChallengeList, getMyCircles, discoverCircles, joinGeneralCircle, leaveGeneralCircle } from '../../utils/api.js'
+import config from '@/config.js'
 
 var DIMENSIONS = [
   { code: 'body', name: '身', icon: '🌏', color: '#8D6E63', bgColor: 'rgba(141,110,99,0.15)', title: '主动健康', desc: '用最适合的方法,从根源调理家人体质,畅享高质量长寿生活' },
@@ -372,7 +364,6 @@ var DIMENSIONS = [
 ]
 
 export default {
-  mixins: [recommendMixin],
   components: {
     PageBanner,
     WuxingSandbox,
@@ -386,8 +377,7 @@ export default {
     CircleCard,
     CircleDetail,
     LoginGuideCard,
-    FamilyChallengeCard,
-    RecommendedSection
+    FamilyChallengeCard
   },
   data() {
     // 从 storage 预取登录态/角色,确保首次渲染即正确(WeChat 渲染层/逻辑层分离架构下 onLoad 执行前已渲染)
@@ -572,10 +562,6 @@ export default {
       this.loadChallenges()
       this.loadMyCircles()
       this.loadDiscoverCircles()
-      this.loadRecommendations('action')
-    },
-    goMoreRecommend: function() {
-      uni.navigateTo({ url: '/pages/discover/index' })
     },
     loadEnergyData: function() {
       var self = this
@@ -992,6 +978,9 @@ export default {
         })
       }, 500)
     },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
+    }
 
   }
 }

+ 5 - 1
cfc-frontend/pages/meal/recipe-detail.vue

@@ -4,7 +4,7 @@
       <!-- 头部信息 -->
       <view class="header">
         <view v-if="recipe.coverImage" class="cover-wrap">
-          <image class="cover" :src="recipe.coverImage" mode="aspectFill" />
+          <image class="cover" :src="getImageUrl(recipe.coverImage)" mode="aspectFill" />
         </view>
         <text class="title">{{ recipe.name }}</text>
         <view v-if="recipe.mealType" class="tag-row">
@@ -78,6 +78,7 @@
 
 <script>
 import { getMealRecommend } from '@/utils/api'
+import config from '@/config.js'
 
 export default {
   name: 'RecipeDetail',
@@ -145,6 +146,9 @@ export default {
       } catch (e) {
         this.stepList = str.split(/[;;\n]/).filter(function(s) { return s.trim() })
       }
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 17 - 3
cfc-frontend/pages/membership/upgrade.vue

@@ -49,7 +49,7 @@
         <view class="period-tabs">
           <view 
             class="period-tab" 
-            v-for="p in periods" 
+            v-for="p in visiblePeriods" 
             :key="p.key"
             :class="selectedPeriod === p.key ? 'period-tab-active' : ''"
             @click="selectPeriod(p.key)"
@@ -89,7 +89,7 @@
         <view class="period-tabs">
           <view 
             class="period-tab" 
-            v-for="p in periods" 
+            v-for="p in visiblePeriods" 
             :key="p.key"
             :class="selectedPeriod === p.key ? 'period-tab-active' : ''"
             @click="selectPeriod(p.key)"
@@ -197,7 +197,8 @@ export default {
       currentLevelName: '免费用户',
       compareData: [
         { name: '商品折扣', free: '普通价格', family: '9折', premium: '8折' },
-        { name: '家庭管理', free: '基础功能', family: '全部功能', premium: '全部功能' },
+        { name: '家庭管理', free: '基础功能', family: '全部功能', premium: '全部功能+优先响应' },
+        { name: '管家服务', free: '—', family: '—', premium: '每周一次' },
         { name: '推荐佣金', free: '不可参与', family: '最高15%', premium: '最高20%' },
         { name: '专属活动', free: '部分参与', family: '全部参与', premium: '优先参与' },
         { name: '成长报告', free: '月度', family: '每周+深度', premium: '每日+深度+专家解读' },
@@ -216,6 +217,18 @@ export default {
     }
   },
   computed: {
+    visiblePeriods() {
+      var self = this
+      return this.periods.filter(function(p) {
+        if (self.selectedLevel === 'FAMILY') {
+          return p.key === 'yearly'
+        }
+        if (self.selectedLevel === 'PREMIUM') {
+          return p.key === 'monthly' || p.key === 'yearly'
+        }
+        return true
+      })
+    },
     availableCoupons() {
       var self = this
       return this.coupons.filter(function(c) {
@@ -236,6 +249,7 @@ export default {
         if (this.membership && this.membership.levelCode && this.membership.levelCode !== 'FREE') {
           this.isFree = false
           this.currentLevelName = this.membership.levelName || '家庭会员'
+          this.selectedLevel = this.membership.levelCode
         } else {
           this.isFree = true
           this.currentLevelName = '免费用户'

+ 6 - 6
cfc-frontend/pages/mind-detail/articles.vue

@@ -56,7 +56,7 @@
         >
           <image
             class="article-cover"
-            :src="item.coverImage || '/static/default-article.png'"
+            :src="getImageUrl(item.coverImage) || '/static/default-article.png'"
             mode="aspectFill"
           />
           <view class="article-body">
@@ -94,6 +94,7 @@
 <script>
 import PageBanner from '../../components/PageBanner.vue'
 import { getArticleCategories, getArticleList } from '../../utils/api.js'
+import config from '@/config.js'
 
 var dimensionConfig = {
   all:   { label: '全部', color: '#64748B', theme: 'mind-wisdom', tagline: '探索知识,滋养成长', quote: '学而时习之,不亦说乎' },
@@ -240,11 +241,10 @@ export default {
       this.loadArticles()
     },
     goDetail(id) {
-      if (this.isLoggedIn) {
-        uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
-      } else {
-        uni.navigateTo({ url: '/pages/mind-detail/articles?redirect=' + encodeURIComponent('/pages/mind-detail/articles') })
-      }
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 6 - 6
cfc-frontend/pages/mind-extra/articles.vue

@@ -84,7 +84,7 @@
           >
             <image
               class="article-cover"
-              :src="item.coverImage || '/static/default-article.png'"
+              :src="getImageUrl(item.coverImage) || '/static/default-article.png'"
               mode="aspectFill"
             />
             <view class="article-body">
@@ -123,6 +123,7 @@
 <script>
 import PageBanner from '../../components/PageBanner.vue'
 import { getArticleCategories, getArticleList, getFeaturedArticles } from '../../utils/api.js'
+import config from '@/config.js'
 
 var dimensionConfig = {
   all:   { label: '全部', color: '#64748B', theme: 'mind-wisdom', tagline: '探索知识,滋养成长', quote: '学而时习之,不亦说乎' },
@@ -308,11 +309,7 @@ export default {
       this.loadArticles()
     },
     goDetail(id) {
-      if (this.isLoggedIn) {
-        uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
-      } else {
-        uni.navigateTo({ url: '/pages/login/login?redirect=' + encodeURIComponent('/pages/mind-extra/articles') })
-      }
+      uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
     },
     goCreate() {
       if (this.isLoggedIn) {
@@ -337,6 +334,9 @@ export default {
       if (!this.searchKeyword) {
         this.searchFocused = false
       }
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 25 - 18
cfc-frontend/pages/mind/index.vue

@@ -113,10 +113,13 @@
       @activityClick="goActivityDetail"
       @moreActivities="goMoreActivities" />
 
-    <DimensionProductList
+    <DimensionProducts
       dimensionCode="mind"
       :familyId="currentChildId"
-      title="为你推荐" />
+      :products="dimensionProducts"
+      title="为你推荐"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
 
     <!-- 推荐阅读 -->
     <ArticleBookshelf
@@ -150,13 +153,6 @@
       </view>
     </view>
 
-    <!-- 个性化推荐 -->
-    <RecommendedSection
-      :items="recommendItems"
-      :hasMore="recommendHasMore"
-      @itemClick="onRecommendItemClick"
-      @more="goMoreRecommend" />
-
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
     <AIFloatingAvatar />
@@ -171,20 +167,17 @@ import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
-import DimensionProductList from '../../components/DimensionProductList.vue'
+import DimensionProducts from '../../components/DimensionProducts.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import PsychCrisisBanner from '../../components/PsychCrisisBanner.vue'
 import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import FamilyTianpanCard from '../../components/FamilyTianpanCard.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import { getVisibleSections, getFeaturedArticles, getEmiReport, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getChildren, getVisibleFamilyMembers, getEnergyOverview, getContactList, getHealthAlerts, getMilestones, getDanReportByChild, getFamilyFortune } from '../../utils/api.js'
+import { getVisibleSections, getFeaturedArticles, getEmiReport, getFamilyEnergySandbox, getTodayTasksByCategory, getActivityList, getProductsByDomain, getChildren, getVisibleFamilyMembers, getEnergyOverview, getContactList, getHealthAlerts, getMilestones, getDanReportByChild, getFamilyFortune } from '../../utils/api.js'
 import config from '../../config.js'
-import RecommendedSection from '../../components/RecommendedSection.vue'
-import recommendMixin from '../../mixins/recommend-mixin'
 
 export default {
-  mixins: [recommendMixin],
-components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProductList, ArticleBookshelf, PsychCrisisBanner, AIFloatingAvatar, FamilyTianpanCard, FamilyMemberStrip, RecommendedSection },
+components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, ArticleBookshelf, PsychCrisisBanner, AIFloatingAvatar, FamilyTianpanCard, FamilyMemberStrip },
   data() {
     return {
       isLoggedIn: false,
@@ -250,6 +243,7 @@ components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnerg
       sandboxData: null,
       dimensionTasks: [],
       dimensionActivities: [],
+      dimensionProducts: [],
       contactList: [],
       healthAlerts: [],
       milestones: [],
@@ -454,7 +448,7 @@ components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnerg
     }
     // 游客也能浏览活动和商品
     this.loadDimensionActivities()
-    this.loadRecommendations('mind')
+    this.loadDimensionProducts()
     this.loadFamilyFortune()
     this._watchPageReady()
   },
@@ -755,8 +749,12 @@ components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnerg
       var id = article.id || article
       uni.navigateTo({ url: '/pages/article-center/article-detail?id=' + id })
     },
-    goMoreRecommend: function() {
-      uni.navigateTo({ url: '/pages/discover/index' })
+    goProductDetail: function(prod) {
+      if (!prod || !prod.id) return
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + prod.id })
+    },
+    goMoreProducts: function() {
+      uni.navigateTo({ url: '/pages/shop/index' })
     },
     // ===== 孩子列表与维度数据 =====
     loadChildren: function() {
@@ -835,6 +833,15 @@ components: { TabTransition, PageBanner, RadarChart, LoginGuideCard, FamilyEnerg
         }
       }).catch(function() { self.dimensionActivities = [] })
     },
+    loadDimensionProducts: function() {
+      var self = this
+      getProductsByDomain('mind', 1, 4).then(function(res) {
+        if (res && res.data) {
+          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
+          self.dimensionProducts = list.slice(0, 4)
+        }
+      }).catch(function() { self.dimensionProducts = [] })
+    },
     onFamilyMemberSelect: function(member) {
       this.selectedMemberId = member.id
     },

+ 6 - 2
cfc-frontend/pages/parent/market/detail.vue

@@ -2,7 +2,7 @@
   <view class="container">
     <!-- 任务模板信息 -->
     <view class="package-header">
-      <image class="cover" :src="pkg.coverImage || '/static/default-cover.png'" mode="aspectFill" />
+      <image class="cover" :src="getImageUrl(pkg.coverImage) || '/static/default-cover.png'" mode="aspectFill" />
       <view class="header-content">
         <text class="name">{{ pkg.name }}</text>
         <view class="meta">
@@ -55,6 +55,7 @@
 
 <script>
 import { getPackage, getPackageItems, purchasePackage } from '@/utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -127,7 +128,10 @@ export default {
             uni.showToast({ title: '应用失败', icon: 'none' })
           }
         }
-      })
+        })
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/parent/market/index.vue

@@ -29,7 +29,7 @@
         :key="pkg.id"
         @click="goToDetail(pkg.id)"
       >
-        <image class="package-cover" :src="pkg.coverImage || '/static/default-cover.png'" mode="aspectFill" />
+        <image class="package-cover" :src="getImageUrl(pkg.coverImage) || '/static/default-cover.png'" mode="aspectFill" />
         <view class="package-content">
           <text class="package-name">{{ pkg.name }}</text>
           <text class="package-desc">{{ pkg.description }}</text>
@@ -51,6 +51,7 @@
 
 <script>
 import { getPublicPackages } from '@/utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -89,6 +90,9 @@ export default {
       uni.navigateTo({
         url: `/pages/parent/market/detail?id=${id}`
       })
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/points/mall.vue

@@ -37,7 +37,7 @@
         >
           <image
             class="product-cover"
-            :src="product.coverImage || '/static/default-product.png'"
+            :src="getImageUrl(product.coverImage) || '/static/default-product.png'"
             mode="aspectFill"
           />
           <view class="product-info">
@@ -125,6 +125,7 @@ import {
   getPointsExchangeRecords,
   getSystemBalance
 } from '@/utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -282,6 +283,9 @@ export default {
       var month = date.getMonth() + 1
       var day = date.getDate()
       return year + '-' + (month < 10 ? '0' + month : month) + '-' + (day < 10 ? '0' + day : day)
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 2
cfc-frontend/pages/promotion/material.vue

@@ -38,7 +38,7 @@
           :key="item.id"
           @click="goDetail(item)"
         >
-          <image class="material-cover" :src="item.coverUrl || '/static/default-cover.png'" mode="aspectFill" />
+          <image class="material-cover" :src="getImageUrl(item.coverUrl) || '/static/default-cover.png'" mode="aspectFill" />
           <view class="material-info">
             <text class="material-title">{{ item.title }}</text>
             <text class="material-tags" v-if="item.tags">{{ item.tags }}</text>
@@ -212,7 +212,10 @@ export default {
         fail: function() {
           uni.showToast({ title: '下载失败', icon: 'none' })
         }
-      })
+        })
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 4 - 1
cfc-frontend/pages/shop/cart/cart.vue

@@ -31,7 +31,7 @@
           <view class="item-left">
             <image
               class="item-image"
-              :src="item.coverImage || '/static/default-product.png'"
+              :src="getImageUrl(item.coverImage) || '/static/default-product.png'"
               mode="aspectFill"
             />
           </view>
@@ -264,6 +264,9 @@ export default {
     formatPriceWithSymbol(price) {
       if (price === null || price === undefined) return '0.00'
       return (Number(price) / 100).toFixed(2)
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 4 - 1
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -57,7 +57,7 @@
         >
           <image
             class="item-image"
-            :src="item.coverImage || '/static/default-product.png'"
+            :src="getImageUrl(item.coverImage) || '/static/default-product.png'"
             mode="aspectFill"
           />
           <view class="item-info">
@@ -501,6 +501,9 @@ export default {
         return phone.substr(0, 3) + '****' + phone.substr(7)
       }
       return phone
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 6 - 2
cfc-frontend/pages/shop/index/index.vue

@@ -79,7 +79,7 @@
           >
             <image
               class="recommend-img"
-              :src="item.image || item.coverImage || '/static/default-product.png'"
+              :src="getImageUrl(item.image || item.coverImage) || '/static/default-product.png'"
               mode="aspectFill"
             />
             <text class="recommend-name">{{ item.name }}</text>
@@ -112,7 +112,7 @@
         >
           <image
             class="cover"
-            :src="item.image || item.coverImage || '/static/default-product.png'"
+            :src="getImageUrl(item.image || item.coverImage) || '/static/default-product.png'"
             mode="aspectFill"
           />
           <view class="card-body">
@@ -143,6 +143,7 @@
 
 <script>
 import { goodsCategory, productList, getMyMembership } from '@/utils/api.js'
+import config from '@/config.js'
 export default {
   data() {
     return {
@@ -278,6 +279,9 @@ export default {
     formatPriceWithSymbol(price) {
       if (price === null || price === undefined) return ''
       return '¥' + parseFloat(price).toFixed(2)
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/shop/order-detail/order-detail.vue

@@ -38,7 +38,7 @@
         <view class="product-card">
           <image
             class="product-cover"
-            :src="order.coverImage || defaultCover(order.productType)"
+            :src="getImageUrl(order.coverImage) || defaultCover(order.productType)"
             mode="aspectFill"
             @error="onCoverError"
           />
@@ -101,6 +101,7 @@
 
 <script>
 import { productOrderDetail, productOrderConfirm, productOrderCancel } from '@/utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -245,6 +246,9 @@ export default {
     formatPriceWithSymbol(price) {
       if (!price && price !== 0) return '-'
       return '¥' + (price / 100).toFixed(2)
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 5 - 1
cfc-frontend/pages/shop/order-list/order-list.vue

@@ -56,7 +56,7 @@
           <view class="card-body">
             <image
               class="product-img"
-              :src="item.coverImage || defaultCover(item.productType)"
+              :src="getImageUrl(item.coverImage) || defaultCover(item.productType)"
               mode="aspectFill"
               @error="onImgError($event, item)"
             />
@@ -108,6 +108,7 @@
 
 <script>
 import { productOrderMy, productOrderCounts, productOrderCancel, productOrderConfirm } from '@/utils/api.js'
+import config from '@/config.js'
 
 var TYPE_ICONS = { activity: '🎯', course: '📖', physical: '📦', digital: '💎', service: '🎓' }
 var DEFAULT_COVER = {
@@ -347,6 +348,9 @@ export default {
     },
     onImgError(e, item) {
       item.coverImage = ''
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 2 - 2
cfc-frontend/pages/user-edit/user-edit.vue

@@ -188,7 +188,7 @@
 			</view>
 		</view>
 
-		<!-- AI助手选择(新用户注册或编辑资料时显示) -->
+		<!-- AI助手选择(已屏蔽)
 		<view class="family-section" v-if="isEdit">
 			<view class="section-title">🤖 选择AI助手</view>
 			<view class="mascot-cards">
@@ -207,7 +207,7 @@
 					</view>
 				</view>
 			</view>
-		</view>
+		</view> -->
 
 		<!-- 家庭绑定(新用户注册时显示) -->
 		<view class="family-section" v-if="isEdit && form.role !== 'teacher' && isNewUserFlow">

+ 5 - 1
cfc-frontend/pages/vendor/products/products.vue

@@ -27,7 +27,7 @@
           class="product-card"
         >
           <view class="card-top">
-            <image class="cover" :src="item.coverImage || '/static/default-product.png'" mode="aspectFill" />
+            <image class="cover" :src="getImageUrl(item.coverImage) || '/static/default-product.png'" mode="aspectFill" />
             <view class="card-info">
               <text class="product-name">{{ item.name }}</text>
               <text class="product-price">{{ formatPriceWithSymbol(item.price) }}</text>
@@ -54,6 +54,7 @@
 
 <script>
 import { productMy, productShelve } from '@/utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -143,6 +144,9 @@ export default {
         off_shelf: '已下架'
       }
       return map[status] || status
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
     }
   }
 }

+ 32 - 21
cfc-frontend/pages/wealth/index.vue

@@ -10,9 +10,12 @@
         quote="富足源于利他与坚持" />
 
       <!-- 商品推荐 -->
-      <DimensionProductList
+      <DimensionProducts
         dimensionCode="wealth"
-        title="为你推荐" />
+        :products="dimensionProducts"
+        title="为你推荐"
+        @productClick="goProductDetail"
+        @moreProducts="goMoreProducts" />
 
       <!-- 推荐阅读 -->
       <ArticleBookshelf
@@ -167,10 +170,13 @@
       </view>
 
       <!-- 商品推荐 -->
-      <DimensionProductList
+      <DimensionProducts
         dimensionCode="wealth"
         :familyId="activeChildId"
-        title="为你推荐" />
+        :products="dimensionProducts"
+        title="为你推荐"
+        @productClick="goProductDetail"
+        @moreProducts="goMoreProducts" />
 
       <!-- 推荐活动 -->
       <DimensionActivities
@@ -187,13 +193,6 @@
         @articleClick="goArticleDetail"
         @moreArticles="goMoreArticles" />
 
-      <!-- 个性化推荐 -->
-      <RecommendedSection
-        :items="recommendItems"
-        :hasMore="recommendHasMore"
-        @itemClick="onRecommendItemClick"
-        @more="goMoreRecommend" />
-
       <!-- 底部菜单 -->
       <view class="menu-list">
         <view class="menu-item" @click="goToProfile">
@@ -217,13 +216,11 @@
 import TabTransition from '../../components/tab-transition.vue'
 import PageBanner from '../../components/PageBanner.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
-import DimensionProductList from '../../components/DimensionProductList.vue'
+import DimensionProducts from '../../components/DimensionProducts.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
 import WealthSubScores from '../../components/WealthSubScores.vue'
 import BodyWealthAlert from '../../components/BodyWealthAlert.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import RecommendedSection from '../../components/RecommendedSection.vue'
-import recommendMixin from '../../mixins/recommend-mixin'
 import {
   verifyPassword,
   getChildren,
@@ -233,12 +230,12 @@ import {
   getCommissionSummary,
   getVisibleFamilyMembers,
   getFeaturedArticles,
-  getActivityList
+  getActivityList,
+  getProductsByDomain
 } from '../../utils/api.js'
 
 export default {
-  mixins: [recommendMixin],
-  components: { TabTransition, PageBanner, DimensionProductList, DimensionActivities, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, RecommendedSection, ArticleBookshelf },
+  components: { TabTransition, PageBanner, DimensionProducts, DimensionActivities, WealthSubScores, BodyWealthAlert, FamilyMemberStrip, ArticleBookshelf },
   data() {
     return {
       showTabTransition: true,
@@ -278,7 +275,8 @@ export default {
       selectedMemberId: null,
       wealthArticles: [],
       articlesLoading: false,
-      dimensionActivities: []
+      dimensionActivities: [],
+      dimensionProducts: []
     }
   },
   computed: {
@@ -312,17 +310,21 @@ export default {
     this.loadWealthDetail()
     this.loadWealthData()
     this.loadFamilyMembersVisible()
-    this.loadRecommendations('wealth')
     this.loadWealthArticles()
     this.loadDimensionActivities()
+    this.loadDimensionProducts()
     this._watchPageReady()
   },
   methods: {
     onFamilyMemberSelect(member) {
       this.selectedMemberId = member.id
     },
-    goMoreRecommend: function() {
-      uni.navigateTo({ url: '/pages/discover/index' })
+    goProductDetail: function(prod) {
+      if (!prod || !prod.id) return
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + prod.id })
+    },
+    goMoreProducts: function() {
+      uni.navigateTo({ url: '/pages/shop/index' })
     },
     loadDimensionActivities: function() {
       var self = this
@@ -333,6 +335,15 @@ export default {
         }
       }).catch(function() { self.dimensionActivities = [] })
     },
+    loadDimensionProducts: function() {
+      var self = this
+      getProductsByDomain('wealth', 1, 4).then(function(res) {
+        if (res && res.data) {
+          var list = Array.isArray(res.data) ? res.data : (res.data.records || [])
+          self.dimensionProducts = list.slice(0, 4)
+        }
+      }).catch(function() { self.dimensionProducts = [] })
+    },
     goActivityDetail: function(act) {
       if (!act || !act.id) return
       uni.navigateTo({ url: '/pages/activity/activity-detail/activity-detail?id=' + act.id })

+ 13 - 17
cfc-frontend/pages/wisdom/index.vue

@@ -99,10 +99,13 @@
       @moreActivities="goMoreActivities" />
 
     <!-- 9. 维度商品 -->
-    <DimensionProductList
+    <DimensionProducts
       dimensionCode="wisdom"
       :familyId="activeChildId"
-      title="为你推荐" />
+      :products="dimensionProducts"
+      title="为你推荐"
+      @productClick="goProductDetail"
+      @moreProducts="goMoreProducts" />
 
     <!-- 10. 推荐阅读 -->
     <ArticleBookshelf
@@ -132,13 +135,6 @@
       </view>
     </view>
 
-    <!-- 个性化推荐 -->
-    <RecommendedSection
-      :items="recommendItems"
-      :hasMore="recommendHasMore"
-      @itemClick="onRecommendItemClick"
-      @more="goMoreRecommend" />
-
     <!-- 底部占位 -->
     <view class="bottom-spacer"></view>
     <AIFloatingAvatar />
@@ -153,17 +149,14 @@ import RadarChart from '../../components/RadarChart.vue'
 import FamilyEnergyBar from '../../components/FamilyEnergyBar.vue'
 import DimensionTasks from '../../components/DimensionTasks.vue'
 import DimensionActivities from '../../components/DimensionActivities.vue'
-import DimensionProductList from '../../components/DimensionProductList.vue'
+import DimensionProducts from '../../components/DimensionProducts.vue'
 import ArticleBookshelf from '../../components/ArticleBookshelf.vue'
 import AIFloatingAvatar from '../../components/AIFloatingAvatar.vue'
 import FamilyMemberStrip from '../../components/FamilyMemberStrip.vue'
-import RecommendedSection from '../../components/RecommendedSection.vue'
-import recommendMixin from '../../mixins/recommend-mixin'
 import { getVisibleSections, getChildren, getTodayTasksByCategory, getActivityList, getProductsByDomain, getFamilyEnergySandbox, getAssessmentLatestResult, getFeaturedArticles, getDanReportByChild } from '../../utils/api.js'
 
 export default {
-  mixins: [recommendMixin],
-  components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProductList, ArticleBookshelf, AIFloatingAvatar, FamilyMemberStrip, RecommendedSection },
+  components: { TabTransition, PageBanner, LoginGuideCard, RadarChart, FamilyEnergyBar, DimensionTasks, DimensionActivities, DimensionProducts, ArticleBookshelf, AIFloatingAvatar, FamilyMemberStrip },
   data() {
     return {
       isLoggedIn: false,
@@ -247,12 +240,15 @@ export default {
     this.loadDimensionProducts()
     // 游客也能浏览文章
     this.loadFeaturedArticles()
-    this.loadRecommendations('wisdom')
     this._watchPageReady()
   },
   methods: {
-    goMoreRecommend: function() {
-      uni.navigateTo({ url: '/pages/discover/index' })
+    goProductDetail: function(prod) {
+      if (!prod || !prod.id) return
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + prod.id })
+    },
+    goMoreProducts: function() {
+      uni.navigateTo({ url: '/pages/shop/index' })
     },
     onFamilyMemberSelect: function(member) {
       this.selectedMemberId = member.id

+ 4 - 0
cfc-frontend/utils/api.js

@@ -1775,6 +1775,10 @@ export const adminEndActivity = (id) => request('/api/activity/end', 'POST', { i
 export const adminListMyActivities = (data) => request('/api/activity/list', 'POST', data)
 // 后台概览列表:获取已发布的活动做统计
 export const adminListAllActivities = (data) => request('/api/activity/list', 'POST', data)
+// 报名审核管理(活动管理员)
+export const adminActivityRegistrationList = (data) => request('/api/admin/activity/registration/list', 'POST', data)
+export const adminApproveRegistration = (data) => request('/api/admin/activity/registration/approve', 'POST', data)
+export const adminRejectRegistration = (data) => request('/api/admin/activity/registration/reject', 'POST', data)
 
 export const getSubscriptionPlans = () => request('/api/subscription/plans', 'POST')
 export const getMySubscription = () => request('/api/subscription/status', 'POST')

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-3250352fcffaf4c60b818cc39371d2bd593cee3d
+22dc331f6e80623cfc9e3900fff2fd427f53f2ac

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

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

+ 1 - 1
cfc-web/package.json

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

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

@@ -4,6 +4,313 @@
 
 ---
 
+## v1.0.737 (2026-08-02)
+
+### 新功能
+- 放开已上架文章和活动的编辑权限,便于初期维护
+
+### 其他
+- - 前端 ArticleEdit/ActivityEdit 的 published 不再进入 readonly
+- - 列表页 published 文章按钮由「查看」改为「编辑」
+- - 保存按钮文案与提示语按状态区分(保存修改/保存草稿)
+- 
+
+
+## v1.0.735 (2026-08-02)
+
+### Bug 修复
+- wangEditor 初始化配置提前到 created 生命周期
+
+### 其他
+- 避免 mounted 阶段编辑器实例已初始化导致配置不生效
+- 
+
+
+## v1.0.734 (2026-08-02)
+
+### Bug 修复
+- 修复外部提交引入的语法错误,恢复小程序构建
+
+### 其他
+- - articles.vue: onSearchBlur 补齐闭合花括号、移除多余 }(e327a0ef 引入)
+- 
+- - 上报间隔 30000→10000,每10秒自动记录该用户已读时长
+- - 退出文章时 pauseReadingTimer 记录当时时长(保留原逻辑)
+- 
+
+### 新功能
+- 文章详情阅读时长每次进入从0开始计时,10秒上报一次
+
+
+## v1.0.733 (2026-08-02)
+
+### 新功能
+- add counterfeit-warning product page, gut-kit image, HRW article image, update sales index
+- 虚拟支付道具映射 实体/Mapper/Service/管理端CRUD接口
+- 新增wechat.appkey与virtual-pay-env配置 小程序虚拟支付paySig签名密钥与环境(0现网/1沙箱)
+- 迁移143 创建virtual_goods_config道具映射表 测评/会员/订阅虚拟支付道具映射,UNIQUE(goods_type,biz_key),幂等
+
+
+## v1.0.732 (2026-08-02)
+
+### 新功能
+- 权益对比区分家庭/高级会员(高级享每周管家服务)+ 家庭会员仅年付、高级会员月/年付费档位
+
+
+## v1.0.731 (2026-08-02)
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- AI/promotion/vendor/parent pages use API_BASE_URL for uploaded images
+- upload/meal/points pages use API_BASE_URL for uploaded images
+- index/product/detail pages use API_BASE_URL for uploaded images
+- activity/guide pages use API_BASE_URL for uploaded cover images
+- order pages use API_BASE_URL for uploaded order cover images
+- shop core pages use API_BASE_URL for uploaded product images
+- product/discover pages use API_BASE_URL for uploaded cover images
+- article pages use API_BASE_URL for uploaded cover images
+- base config/components use API_BASE_URL for uploaded image URLs
+
+
+## v1.0.730 (2026-08-02)
+
+### Bug 修复
+- WechatService 用 StreamUtils.copyToByteArray 替代 readAllBytes,兼容 Java 8 编译
+
+
+## v1.0.729 (2026-08-02)
+
+### 新功能
+- 微信内容安全检测(msg_sec_check + img_sec_check)
+
+### 其他
+- - WechatService: access_token 加缓存(TTL 7000s); 实现 msg_sec_check / img_sec_check
+- - MediaController: 上传图片/文本检测 (P0)
+- - AdminArticleController: 文章图片/标题/正文检测 (P1)
+- - HealthReportController: 健康报告图片上传检测 (P1)
+- - ServiceRoleApplicationController: 资质图片上传检测 (P1)
+- - 微信API异常时 fail-open 不阻塞上传; testMode 跳过检测
+- 
+
+
+## v1.0.728 (2026-08-02)
+
+### 其他
+- 屏蔽选择AI助手功能
+
+
+## v1.0.727 (2026-08-02)
+
+### Bug 修复
+- 富文本编辑器图片上传路径和网络图片弹窗被遮挡
+
+### 其他
+- - ArticleEdit/ProductEdit/ActivityEdit: onSuccess 内拼接后端 baseURL 生成完整图片 URL
+- 
+
+
+## v1.0.726 (2026-08-02)
+
+### Bug 修复
+- ErrorLoggingFilter 捕获链式异常并记录500日志; DimensionWeightController 参数校验; GlobalExceptionHandler 兜底异常处理
+
+
+## v1.0.725 (2026-08-02)
+
+### 新功能
+- add DAN professional assessment product page with 4-tier packages
+
+### 其他
+- - 7 report packages by age group (A1/A2/B2/B3/B4/B5/B6, C1 excluded from retail)
+- - 4-tier pricing: single 699 / dual 1280 / triple 1860 / quadruple 2390
+- - Package combination rules per user spec (A2+B4 golden combo, +B3/B5/B6 for 3+ packs)
+- - WeChat native QR payment flow with plan selection modal
+- 
+
+### Bug 修复
+- 五维首页为你推荐商品区改为使用公开接口数据, 未选孩子也能显示
+
+
+## v1.0.724 (2026-08-02)
+
+### 新功能
+- 活动中心增强——管理员活动管理+已报名状态标记+报名截止时间+未报名签到扣'行'维度能量
+
+
+## v1.0.723 (2026-08-02)
+
+### Bug 修复
+- simplify payment QR instruction text
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 文档
+- 《家的算法》全书插图占位33处+插图规划清单+知识产权查证档案(24730/章鱼图/珍珠图)
+
+
+## v1.0.722 (2026-08-02)
+
+### Bug 修复
+- set gut testing price to ¥1,314
+- update payment QR instructions for WeChat browser users
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.721 (2026-08-02)
+
+### Bug 修复
+- DatabaseInitializer 清理 — 修正 modifyColumnToText 表名、移除已删表的残留迁移
+- skip OAuth2 JSAPI flow, use Native QR code directly for WeChat browser
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 新功能
+- add payment to water ionizer and turmeric product pages
+
+
+## v1.0.720 (2026-08-02)
+
+### 新功能
+- add gut-testing product links to articles
+- sticky bottom payment bar like e-commerce
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.719 (2026-08-02)
+
+### Bug 修复
+- show contact info in QR payment popup
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 各调用页面已绑定 @articleClick=goArticleDetail/goDetail,直达详情
+- 组件从 895 行精简至 519 行
+- 
+
+### 新功能
+- 推荐阅读点击封面直接进入文章详情页
+
+
+## v1.0.718 (2026-08-02)
+
+### Bug 修复
+- sync ProductOrder for admin order management
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.717 (2026-08-02)
+
+### 新功能
+- save contact info to package_orders, keep SVG flow only
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 改为 scroll-view 保持默认 block,内部 .bs-books-inner 用 inline-flex 横向排列
+- 
+
+### Bug 修复
+- ArticleBookshelf 书籍横向排列 — scroll-view 内包 inline-flex 容器
+
+
+## v1.0.716 (2026-08-02)
+
+### Bug 修复
+- show product info in payment popup, auto-close on success
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.715 (2026-08-02)
+
+### Bug 修复
+- missing braces in JS, add backend QR code endpoint, use backend QR code instead of external API
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 重构
+- gut-testing 删除 gut-kit-6/8 图片块,更新 8 大检测维度解析长图为压缩版
+
+
+## v1.0.714 (2026-08-02)
+
+### Bug 修复
+- add CORS origin for www.cf-club.com, fix QR code display, add diagnostic filter
+- 文章列表点击直接进入详情页,未登录也可阅读公开文章
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- - 五维首页(index-home/body/wisdom/mind/wealth)与 action 页统一使用 DimensionProducts
+- - 统一商品跳转 goProductDetail/goMoreProducts,移除登录跳转冗余分支
+- - 修复 DimensionProducts 空态文案依赖 isLoggedIn 的逻辑
+- 
+- - 每屏固定3本,多余横向滑动,指示器按3本一组并随滚动高亮
+- - 骨架屏同步改为3个封面占位
+- 
+
+### 重构
+- 统一五维首页商品推荐组件为 DimensionProducts
+
+### 新功能
+- ArticleBookshelf 改为竖版封面风格,一行3本横向滑动
+
+
 ## v1.0.713 (2026-08-02)
 
 ### 文档

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

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.713
+> 当前版本: v1.0.737
 
 ## 历史版本
 
@@ -8,6 +8,313 @@
 
 ---
 
+## v1.0.737 (2026-08-02)
+
+### 新功能
+- 放开已上架文章和活动的编辑权限,便于初期维护
+
+### 其他
+- - 前端 ArticleEdit/ActivityEdit 的 published 不再进入 readonly
+- - 列表页 published 文章按钮由「查看」改为「编辑」
+- - 保存按钮文案与提示语按状态区分(保存修改/保存草稿)
+- 
+
+
+## v1.0.735 (2026-08-02)
+
+### Bug 修复
+- wangEditor 初始化配置提前到 created 生命周期
+
+### 其他
+- 避免 mounted 阶段编辑器实例已初始化导致配置不生效
+- 
+
+
+## v1.0.734 (2026-08-02)
+
+### Bug 修复
+- 修复外部提交引入的语法错误,恢复小程序构建
+
+### 其他
+- - articles.vue: onSearchBlur 补齐闭合花括号、移除多余 }(e327a0ef 引入)
+- 
+- - 上报间隔 30000→10000,每10秒自动记录该用户已读时长
+- - 退出文章时 pauseReadingTimer 记录当时时长(保留原逻辑)
+- 
+
+### 新功能
+- 文章详情阅读时长每次进入从0开始计时,10秒上报一次
+
+
+## v1.0.733 (2026-08-02)
+
+### 新功能
+- add counterfeit-warning product page, gut-kit image, HRW article image, update sales index
+- 虚拟支付道具映射 实体/Mapper/Service/管理端CRUD接口
+- 新增wechat.appkey与virtual-pay-env配置 小程序虚拟支付paySig签名密钥与环境(0现网/1沙箱)
+- 迁移143 创建virtual_goods_config道具映射表 测评/会员/订阅虚拟支付道具映射,UNIQUE(goods_type,biz_key),幂等
+
+
+## v1.0.732 (2026-08-02)
+
+### 新功能
+- 权益对比区分家庭/高级会员(高级享每周管家服务)+ 家庭会员仅年付、高级会员月/年付费档位
+
+
+## v1.0.731 (2026-08-02)
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### Bug 修复
+- AI/promotion/vendor/parent pages use API_BASE_URL for uploaded images
+- upload/meal/points pages use API_BASE_URL for uploaded images
+- index/product/detail pages use API_BASE_URL for uploaded images
+- activity/guide pages use API_BASE_URL for uploaded cover images
+- order pages use API_BASE_URL for uploaded order cover images
+- shop core pages use API_BASE_URL for uploaded product images
+- product/discover pages use API_BASE_URL for uploaded cover images
+- article pages use API_BASE_URL for uploaded cover images
+- base config/components use API_BASE_URL for uploaded image URLs
+
+
+## v1.0.730 (2026-08-02)
+
+### Bug 修复
+- WechatService 用 StreamUtils.copyToByteArray 替代 readAllBytes,兼容 Java 8 编译
+
+
+## v1.0.729 (2026-08-02)
+
+### 新功能
+- 微信内容安全检测(msg_sec_check + img_sec_check)
+
+### 其他
+- - WechatService: access_token 加缓存(TTL 7000s); 实现 msg_sec_check / img_sec_check
+- - MediaController: 上传图片/文本检测 (P0)
+- - AdminArticleController: 文章图片/标题/正文检测 (P1)
+- - HealthReportController: 健康报告图片上传检测 (P1)
+- - ServiceRoleApplicationController: 资质图片上传检测 (P1)
+- - 微信API异常时 fail-open 不阻塞上传; testMode 跳过检测
+- 
+
+
+## v1.0.728 (2026-08-02)
+
+### 其他
+- 屏蔽选择AI助手功能
+
+
+## v1.0.727 (2026-08-02)
+
+### Bug 修复
+- 富文本编辑器图片上传路径和网络图片弹窗被遮挡
+
+### 其他
+- - ArticleEdit/ProductEdit/ActivityEdit: onSuccess 内拼接后端 baseURL 生成完整图片 URL
+- 
+
+
+## v1.0.726 (2026-08-02)
+
+### Bug 修复
+- ErrorLoggingFilter 捕获链式异常并记录500日志; DimensionWeightController 参数校验; GlobalExceptionHandler 兜底异常处理
+
+
+## v1.0.725 (2026-08-02)
+
+### 新功能
+- add DAN professional assessment product page with 4-tier packages
+
+### 其他
+- - 7 report packages by age group (A1/A2/B2/B3/B4/B5/B6, C1 excluded from retail)
+- - 4-tier pricing: single 699 / dual 1280 / triple 1860 / quadruple 2390
+- - Package combination rules per user spec (A2+B4 golden combo, +B3/B5/B6 for 3+ packs)
+- - WeChat native QR payment flow with plan selection modal
+- 
+
+### Bug 修复
+- 五维首页为你推荐商品区改为使用公开接口数据, 未选孩子也能显示
+
+
+## v1.0.724 (2026-08-02)
+
+### 新功能
+- 活动中心增强——管理员活动管理+已报名状态标记+报名截止时间+未报名签到扣'行'维度能量
+
+
+## v1.0.723 (2026-08-02)
+
+### Bug 修复
+- simplify payment QR instruction text
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 文档
+- 《家的算法》全书插图占位33处+插图规划清单+知识产权查证档案(24730/章鱼图/珍珠图)
+
+
+## v1.0.722 (2026-08-02)
+
+### Bug 修复
+- set gut testing price to ¥1,314
+- update payment QR instructions for WeChat browser users
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.721 (2026-08-02)
+
+### Bug 修复
+- DatabaseInitializer 清理 — 修正 modifyColumnToText 表名、移除已删表的残留迁移
+- skip OAuth2 JSAPI flow, use Native QR code directly for WeChat browser
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 新功能
+- add payment to water ionizer and turmeric product pages
+
+
+## v1.0.720 (2026-08-02)
+
+### 新功能
+- add gut-testing product links to articles
+- sticky bottom payment bar like e-commerce
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.719 (2026-08-02)
+
+### Bug 修复
+- show contact info in QR payment popup
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 各调用页面已绑定 @articleClick=goArticleDetail/goDetail,直达详情
+- 组件从 895 行精简至 519 行
+- 
+
+### 新功能
+- 推荐阅读点击封面直接进入文章详情页
+
+
+## v1.0.718 (2026-08-02)
+
+### Bug 修复
+- sync ProductOrder for admin order management
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.717 (2026-08-02)
+
+### 新功能
+- save contact info to package_orders, keep SVG flow only
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 改为 scroll-view 保持默认 block,内部 .bs-books-inner 用 inline-flex 横向排列
+- 
+
+### Bug 修复
+- ArticleBookshelf 书籍横向排列 — scroll-view 内包 inline-flex 容器
+
+
+## v1.0.716 (2026-08-02)
+
+### Bug 修复
+- show product info in payment popup, auto-close on success
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.715 (2026-08-02)
+
+### Bug 修复
+- missing braces in JS, add backend QR code endpoint, use backend QR code instead of external API
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+### 重构
+- gut-testing 删除 gut-kit-6/8 图片块,更新 8 大检测维度解析长图为压缩版
+
+
+## v1.0.714 (2026-08-02)
+
+### Bug 修复
+- add CORS origin for www.cf-club.com, fix QR code display, add diagnostic filter
+- 文章列表点击直接进入详情页,未登录也可阅读公开文章
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- 
+- - 五维首页(index-home/body/wisdom/mind/wealth)与 action 页统一使用 DimensionProducts
+- - 统一商品跳转 goProductDetail/goMoreProducts,移除登录跳转冗余分支
+- - 修复 DimensionProducts 空态文案依赖 isLoggedIn 的逻辑
+- 
+- - 每屏固定3本,多余横向滑动,指示器按3本一组并随滚动高亮
+- - 骨架屏同步改为3个封面占位
+- 
+
+### 重构
+- 统一五维首页商品推荐组件为 DimensionProducts
+
+### 新功能
+- ArticleBookshelf 改为竖版封面风格,一行3本横向滑动
+
+
 ## v1.0.713 (2026-08-02)
 
 ### 文档

+ 3 - 2
cfc-web/src/styles/admin-tokens.css

@@ -325,7 +325,8 @@
 .rich-editor {
   border: 1px solid var(--color-border-base);
   border-radius: var(--border-radius-base);
-  overflow: hidden;
+  position: relative;
+  overflow: visible;
 }
 
 .rich-editor /deep/ .w-e-toolbar {
@@ -338,7 +339,7 @@
 }
 
 .rich-editor /deep/ .w-e-panel-container {
-  z-index: 2000;
+  z-index: 5000;
 }
 
 /* ========================

+ 9 - 6
cfc-web/src/views/admin/ActivityEdit.vue

@@ -6,8 +6,8 @@
         <div class="admin-page-actions">
           <el-button @click="$router.push('/activities')">返回列表</el-button>
           <template v-if="!readonly && activityStatus !== 'rejected'">
-            <el-button type="primary" @click="saveActivity(false)" :loading="saving">保存草稿</el-button>
-            <el-button v-if="isEdit" type="success" @click="handleSubmitReview" :loading="reviewLoading">提交审核</el-button>
+            <el-button type="primary" @click="saveActivity(false)" :loading="saving">{{ activityStatus === 'published' ? '保存修改' : '保存草稿' }}</el-button>
+            <el-button v-if="isEdit && activityStatus !== 'published'" type="success" @click="handleSubmitReview" :loading="reviewLoading">提交审核</el-button>
           </template>
           <template v-if="activityStatus === 'rejected'">
             <el-button type="primary" @click="handleReDraft" :loading="reDraftLoading">保存到草稿箱重新编辑</el-button>
@@ -247,11 +247,13 @@ export default {
   },
   methods: {
     initEditorConfig() {
+      const baseURL = process.env.VUE_APP_BASE_API || ''
+      const token = localStorage.getItem('token')
       this.editorConfig = {
         MENU_CONF: {
           uploadImage: {
-            server: this.uploadActionUrl,
-            headers: this.uploadHeaders,
+            server: baseURL + '/api/admin/articles/upload/image',
+            headers: token ? { Authorization: 'Bearer ' + token } : {},
             maxNumberOfFiles: 1,
             fieldName: 'file',
             customAlert: (msg, type) => {
@@ -259,7 +261,8 @@ export default {
             },
             onSuccess(res, insertFn) {
               if (res.code === 200) {
-                insertFn(res.data.url || res.data)
+                const url = res.data.url || res.data
+                insertFn(baseURL + url)
               }
             }
           }
@@ -278,7 +281,7 @@ export default {
           this.activityStatus = d.status || ''
           this.auditReason = d.auditReason || ''
           // Set readonly based on workflow status
-          if (d.status === 'pending' || d.status === 'rejected' || d.status === 'published' || d.status === 'withdrawn') {
+          if (d.status === 'pending' || d.status === 'rejected' || d.status === 'withdrawn') {
             this.readonly = true
           } else if (this.$route.query.readonly === '1') {
             this.readonly = true

+ 23 - 22
cfc-web/src/views/admin/ArticleEdit.vue

@@ -6,7 +6,7 @@
         <div class="admin-page-actions">
           <el-button @click="$router.push('/article-manage')">返回列表</el-button>
           <template v-if="!readonly">
-            <el-button type="primary" @click="handleSave(false)" :loading="saving">保存草稿</el-button>
+            <el-button type="primary" @click="handleSave(false)" :loading="saving">{{ articleStatus === 'published' ? '保存修改' : '保存草稿' }}</el-button>
             <el-button type="success" @click="handleSubmitReview" :loading="reviewLoading" v-if="isEdit && articleStatus === 'draft' && !articleWasPublished">提交审核</el-button>
             <el-button type="success" @click="handleSave(true)" :loading="publishing" v-if="!isEdit || articleStatus === 'draft'">直接发布</el-button>
           </template>
@@ -246,29 +246,30 @@ export default {
     next()
   },
   methods: {
-    initEditorConfig() {
-      const baseURL = process.env.VUE_APP_BASE_API || ''
-      const token = localStorage.getItem('token')
-      this.editorConfig = {
-        MENU_CONF: {
-          uploadImage: {
-            server: baseURL + '/api/admin/articles/upload/image',
-            headers: token ? { Authorization: 'Bearer ' + token } : {},
-            maxNumberOfFiles: 1,
-            maxFileSize: 5 * 1024 * 1024,
-            fieldName: 'file',
-            customAlert: (msg, type) => {
-              this.$message && this.$message({ message: msg, type: type || 'error' })
-            },
-            onSuccess(res, insertFn) {
-              if (res.code === 200) {
-                insertFn(res.data.url || res.data)
+      initEditorConfig() {
+        const baseURL = process.env.VUE_APP_BASE_API || ''
+        const token = localStorage.getItem('token')
+        this.editorConfig = {
+          MENU_CONF: {
+            uploadImage: {
+              server: baseURL + '/api/admin/articles/upload/image',
+              headers: token ? { Authorization: 'Bearer ' + token } : {},
+              maxNumberOfFiles: 1,
+              maxFileSize: 5 * 1024 * 1024,
+              fieldName: 'file',
+              customAlert: (msg, type) => {
+                this.$message && this.$message({ message: msg, type: type || 'error' })
+              },
+              onSuccess(res, insertFn) {
+                if (res.code === 200) {
+                  const url = res.data.url || res.data
+                  insertFn(baseURL + url)
+                }
               }
             }
           }
         }
-      }
-    },
+      },
     handleEditorCreated(editor) {
       this.editorInstance = editor
     },
@@ -305,7 +306,7 @@ export default {
             this.readonly = true
           } else if (d.status === 'rejected') {
             this.readonly = true
-          } else if (d.status === 'published' || d.status === 'withdrawn') {
+          } else if (d.status === 'withdrawn') {
             this.readonly = true
           } else {
             this.readonly = this.$route.query.readonly === '1'
@@ -456,7 +457,7 @@ export default {
             console.error('保存维度权重失败', e)
           }
         }
-        this.$message.success(publishNow ? '文章已发布' : '草稿已保存')
+        this.$message.success(publishNow ? '文章已发布' : (this.articleStatus === 'published' ? '修改已保存' : '草稿已保存'))
         this.$router.push('/article-manage')
       } catch (e) {
         this.$message.error(e.message || '保存失败')

+ 2 - 2
cfc-web/src/views/admin/ArticleManage.vue

@@ -161,9 +161,9 @@
                 </el-dropdown-menu>
               </el-dropdown>
             </template>
-            <!-- 发布中:查看 + 下架 + 精选 -->
+            <!-- 发布中:编辑 + 下架 + 精选 -->
             <template v-else-if="row.status === 'published'">
-              <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id + '&readonly=1')">查看</el-button>
+              <el-button size="mini" type="primary" @click="$router.push('/article-edit?id=' + row.id)">编辑</el-button>
               <el-dropdown trigger="hover" @command="(cmd) => handleActionCmd(row, cmd)">
                 <el-button size="mini">
                   更多<i class="el-icon-arrow-down el-icon--right"></i>

+ 6 - 2
cfc-web/src/views/admin/ProductEdit.vue

@@ -268,7 +268,9 @@ export default {
             },
             onSuccess(res, insertFn) {
               if (res.code === 200) {
-                insertFn(res.data.url || res.data)
+                const baseURL = process.env.VUE_APP_BASE_API || ''
+                const url = res.data.url || res.data
+                insertFn(baseURL + url)
               }
             }
           }
@@ -300,9 +302,11 @@ export default {
       return this.supplySystems.find(s => s.id === this.form.distributionSystemId)
     }
   },
-  mounted() {
+  created() {
     this.editorConfig.MENU_CONF.uploadImage.server = this.uploadActionUrl
     this.editorConfig.MENU_CONF.uploadImage.headers = this.uploadHeaders
+  },
+  mounted() {
     this.loadSupplySystems()
     this.loadPredefinedFields()
     if (this.isEdit) {

+ 1 - 1
web/articles/alzheimer.html

@@ -1037,7 +1037,7 @@
     <h3>1.1 认知筛查</h3>
     <p>MoCA(Montreal Cognitive Assessment,比MMSE更敏感)≥60岁每年一次。</p>
     
-    <h3>1.2 肠道菌群检测</h3>
+    <h3>1.2 <a href="../product-intro/gut-testing.html" style="color:var(--accent);text-decoration:underline;">肠道菌群检测</a></h3>
     <p>产丁酸菌丰度↓是神经炎症风险的肠道标志。</p>
     
     <h3>1.3 Aβ/Tau生物标志物(研究级)</h3>

+ 1 - 1
web/articles/cardio-cerebro.html

@@ -1058,7 +1058,7 @@
     <!-- 菌群检测评估肠漏 -->
     <div style="background:var(--teal-light);border:1px solid rgba(20,143,119,0.2);border-radius:12px;padding:1rem 1.2rem;margin-bottom:1.5rem;">
       <p style="margin:0;font-size:0.95rem;line-height:1.8;">
-        <strong>🧬 为什么肠道菌群检测是炎症评估中不可缺失的一环?</strong>hsCRP反映的是"炎症已经存在"的后果,而肠道菌群检测反映的是"炎症从哪里来"的根源——肠漏风险。通过宏基因组测序评估<strong>产丁酸菌丰度、条件致病菌比例、LPS释放潜力</strong>,可以在hsCRP升高之前就识别出肠漏高风险人群(<em>van den Munckhof et al., Eur J Prev Cardiol 2024</em>)。建议将肠道菌群检测纳入常规炎症评估体系,尤其是对有消化道症状(腹胀/腹泻/便秘)、抗生素使用史、或家族心血管病史的人群。<strong>检测频率:每1-2年一次,干预后3-6个月复查评估效果。</strong>
+        <strong>🧬 为什么<a href="../product-intro/gut-testing.html" style="color:var(--accent);text-decoration:underline;">肠道菌群检测</a>是炎症评估中不可缺失的一环?</strong>hsCRP反映的是"炎症已经存在"的后果,而肠道菌群检测反映的是"炎症从哪里来"的根源——肠漏风险。通过宏基因组测序评估<strong>产丁酸菌丰度、条件致病菌比例、LPS释放潜力</strong>,可以在hsCRP升高之前就识别出肠漏高风险人群(<em>van den Munckhof et al., Eur J Prev Cardiol 2024</em>)。建议将肠道菌群检测纳入常规炎症评估体系,尤其是对有消化道症状(腹胀/腹泻/便秘)、抗生素使用史、或家族心血管病史的人群。<strong>检测频率:每1-2年一次,干预后3-6个月复查评估效果。</strong>
       </p>
     </div>
 

+ 150 - 1
web/articles/enagic-turmeric.html

@@ -264,6 +264,155 @@
 </div>
 
 <div id="footer-placeholder"></div>
-<script src="../js/footer.js"></script>
+
+<!-- 底部固定支付栏 -->
+<style>
+.sticky-bar{position:fixed;bottom:0;left:0;right:0;background:rgba(255,255,255,0.97);backdrop-filter:blur(12px);border-top:1px solid #e2e8f0;padding:0.8rem 1.5rem;z-index:999;display:flex;align-items:center;justify-content:space-between;box-shadow:0 -4px 20px rgba(0,0,0,0.06)}
+.sticky-bar .price{font-size:1.5rem;font-weight:800;color:#0d9488}
+.sticky-bar .label{font-size:0.8rem;color:var(--text-muted);margin-bottom:0.1rem}
+.sticky-btn{background:linear-gradient(135deg,#0d9488,#14b8a6);color:#fff;border:none;border-radius:8px;padding:0.7rem 2rem;font-size:1rem;font-weight:600;cursor:pointer;transition:all .2s;white-space:nowrap}
+.sticky-btn:hover{transform:translateY(-1px);box-shadow:0 4px 12px rgba(20,184,166,0.3)}
+body{padding-bottom:70px}
+</style>
+<div class="sticky-bar">
+  <div>
+    <div class="label">Enagic UKON Sigma 姜黄素</div>
+    <div><span class="price">¥6,700</span></div>
+  </div>
+  <button class="sticky-btn" onclick="document.getElementById('orderForm').style.visibility='visible';document.getElementById('orderForm').style.opacity='1'">立即购买</button>
+</div>
+
+<!-- Order Form Modal -->
+<div id="orderForm" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;visibility:hidden;opacity:0;transition:opacity 0.2s;" onclick="if(event.target===this){this.style.visibility='hidden';this.style.opacity='0'}">
+  <div style="background:#fff;border-radius:16px;padding:2rem;max-width:420px;width:90%;position:relative;">
+    <button style="position:absolute;top:12px;right:12px;background:none;border:none;font-size:24px;cursor:pointer;color:#999" onclick="var f=document.getElementById('orderForm');f.style.visibility='hidden';f.style.opacity='0'">×</button>
+    <h3 style="margin-bottom:1.2rem;font-size:1.2rem;">Enagic 姜黄素 · 下单</h3>
+    <div style="margin-bottom:1rem">
+      <label style="display:block;font-size:0.85rem;color:var(--text-secondary);margin-bottom:0.3rem">联系人</label>
+      <input id="orderName" type="text" placeholder="请输入您的姓名" style="width:100%;padding:0.6rem 0.8rem;border:1px solid #e2e8f0;border-radius:8px;font-size:0.95rem;box-sizing:border-box">
+    </div>
+    <div style="margin-bottom:1rem">
+      <label style="display:block;font-size:0.85rem;color:var(--text-secondary);margin-bottom:0.3rem">手机号</label>
+      <input id="orderPhone" type="tel" placeholder="请输入手机号" style="width:100%;padding:0.6rem 0.8rem;border:1px solid #e2e8f0;border-radius:8px;font-size:0.95rem;box-sizing:border-box">
+    </div>
+    <div style="margin-bottom:1rem">
+      <label style="display:block;font-size:0.85rem;color:var(--text-secondary);margin-bottom:0.3rem">收件地址</label>
+      <input id="orderAddress" type="text" placeholder="省/市/区 + 详细地址" style="width:100%;padding:0.6rem 0.8rem;border:1px solid #e2e8f0;border-radius:8px;font-size:0.95rem;box-sizing:border-box">
+    </div>
+    <div style="margin-bottom:1.2rem;padding:0.8rem;background:#f0fdfa;border-radius:8px;display:flex;justify-content:space-between;align-items:center">
+      <span style="font-size:0.9rem;color:var(--text-secondary)">合计</span>
+      <span style="font-size:1.5rem;font-weight:800;color:#0d9488">¥6,700</span>
+    </div>
+    <button id="payBtn" onclick="submitOrder(670000,'Enagic UKON Sigma姜黄素')" style="width:100%;padding:0.8rem;background:linear-gradient(135deg,#0d9488,#14b8a6);color:#fff;border:none;border-radius:8px;font-size:1rem;font-weight:600;cursor:pointer">微信支付</button>
+    <p style="font-size:0.75rem;color:var(--text-muted);margin-top:0.8rem;text-align:center">支付由微信支付安全处理</p>
+  </div>
+</div>
+
+<script>
+function submitOrder(amount, productName) {
+  function isWechatBrowser() { return navigator.userAgent.toLowerCase().indexOf('micromessenger') !== -1; }
+  var name = document.getElementById('orderName').value.trim();
+  var phone = document.getElementById('orderPhone').value.trim();
+  var address = document.getElementById('orderAddress').value.trim();
+  if (!name) { alert('请输入联系人姓名'); return; }
+  if (!phone || !/^1\d{10}$/.test(phone)) { alert('请输入正确的手机号'); return; }
+  if (!address) { alert('请输入收件地址'); return; }
+  
+  var btn = document.getElementById('payBtn');
+  btn.disabled = true;
+  btn.textContent = '提交中...';
+  
+  var orderNo = 'TUR' + Date.now() + Math.random().toString(36).substr(2,4).toUpperCase();
+  window._orderContact = { name: name, phone: phone, address: address };
+  
+  fetch('/api/payment/wechat/native', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      orderNo: orderNo,
+      description: productName,
+      amount: amount,
+      contactName: name,
+      contactPhone: phone,
+      contactAddress: address
+    })
+  })
+  .then(function(r) { return r.json(); })
+  .then(function(data) {
+    if (data.code === 200 && data.data && data.data.codeUrl) {
+      document.getElementById('orderForm').style.visibility = 'hidden';
+      document.getElementById('orderForm').style.opacity = '0';
+      showQrCode(data.data.codeUrl, orderNo, productName, amount);
+    } else {
+      alert('下单失败: ' + (data.message || ''));
+      btn.disabled = false;
+      btn.textContent = '微信支付';
+    }
+  })
+  .catch(function() {
+    alert('网络请求失败');
+    btn.disabled = false;
+    btn.textContent = '微信支付';
+  });
+}
+
+function showQrCode(codeUrl, orderNo, productName, amount) {
+  var c = window._orderContact || {};
+  var div = document.createElement('div');
+  div.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center';
+  div.onclick = function(e) { if (e.target === this) this.remove(); };
+  div.innerHTML = '<div style="background:#fff;border-radius:16px;padding:2rem;text-align:center;max-width:340px">' +
+    '<h3 style="margin-bottom:0.8rem;font-size:1.1rem;">用其他手机扫码支付</h3>' +
+    '<div style="background:#f0fdfa;border-radius:8px;padding:0.8rem;margin-bottom:0.8rem;text-align:left">' +
+      '<div style="font-size:0.85rem;color:var(--text-secondary);margin-bottom:0.3rem">' + productName + '</div>' +
+      '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.3rem">' +
+        '<span style="font-size:0.8rem;color:var(--text-muted)">1份</span>' +
+        '<span style="font-size:1.1rem;font-weight:700;color:#0d9488">¥' + (amount/100).toLocaleString() + '</span>' +
+      '</div>' +
+      '<div style="border-top:1px dashed #e2e8f0;margin:0.5rem 0;padding-top:0.5rem;font-size:0.8rem;color:var(--text-muted)">' +
+        '<div>联系人: ' + (c.name || '') + '</div>' +
+        '<div>手机号: ' + (c.phone || '') + '</div>' +
+        '<div>地址: ' + (c.address || '') + '</div>' +
+      '</div>' +
+    '</div>' +
+    '<div style="width:200px;height:200px;margin:0 auto 0.8rem;display:flex;align-items:center;justify-content:center;border-radius:8px;overflow:hidden">' +
+      '<img src="/api/payment/qrcode?text=' + encodeURIComponent(codeUrl) + '" alt="支付二维码" style="width:200px;height:200px">' +
+    '</div>' +
+    '<p style="font-size:0.8rem;color:#999">请用其他手机微信扫码完成支付</p>' +
+    '<button onclick="this.closest(\'div[style]\').remove()" style="margin-top:0.8rem;padding:0.5rem 2rem;border:1px solid #e2e8f0;border-radius:8px;background:#fff;cursor:pointer">取消支付</button>' +
+    '</div>';
+  document.body.appendChild(div);
+  
+  var pollTimer = setInterval(function() {
+    fetch('/api/payment/wechat/status', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ orderNo: orderNo })
+    })
+    .then(function(r) { return r.json(); })
+    .then(function(data) {
+      if (data.code === 200 && data.data === true) {
+        clearInterval(pollTimer);
+        div.innerHTML = '<div style="background:#fff;border-radius:16px;padding:2rem;text-align:center;max-width:320px">' +
+          '<div style="font-size:3rem;margin-bottom:0.5rem">✅</div>' +
+          '<h3 style="color:#0d9488;font-size:1.2rem;margin-bottom:0.5rem">支付成功!</h3>' +
+          '<div style="background:#f0fdfa;border-radius:8px;padding:0.8rem;margin-bottom:1rem;text-align:left">' +
+            '<div style="font-size:0.85rem;color:var(--text-secondary);margin-bottom:0.3rem">' + productName + '</div>' +
+            '<div style="display:flex;justify-content:space-between;align-items:center">' +
+              '<span style="font-size:0.8rem;color:var(--text-muted)">已付款</span>' +
+              '<span style="font-size:1.1rem;font-weight:700;color:#0d9488">¥' + (amount/100).toLocaleString() + '</span>' +
+            '</div>' +
+          '</div>' +
+          '<p style="font-size:0.85rem;color:#999;margin-bottom:0.5rem">我们将在24小时内与您联系确认</p>' +
+          '<p style="font-size:0.75rem;color:#bbb">窗口将在3秒后自动关闭</p>' +
+          '</div>';
+        setTimeout(function() { div.remove(); }, 3000);
+      }
+    })
+    .catch(function() {});
+  }, 3000);
+  setTimeout(function() { clearInterval(pollTimer); }, 120000);
+}
+</script>
 </body>
 </html>

+ 1 - 1
web/articles/geo-faq-1-allergy.html

@@ -327,7 +327,7 @@
       <h2>三、从"对症治疗"到"对因调理"</h2>
       <p>传统过敏治疗以<strong>"对症"</strong>为主——湿疹抹药膏、鼻炎喷激素、过敏吃抗组胺药。这些方法可以暂时控制症状,但无法解决根本问题,停药后容易复发。</p>
       <p>基于肠道菌群-免疫-过敏三角关系的科学认识,现代的过敏管理思路正在转向<strong>"对因调理"</strong>——从修复肠道菌群入手,重建免疫系统的正常应答机制。</p>
-      <p>而这一切的第一步,是<strong>了解孩子肠道菌群的真实状况</strong>——这正是菌群检测的核心价值,检测数据会沉淀到孩子的健康档案中。</p>
+      <p>而这一切的第一步,是<strong>了解孩子肠道菌群的真实状况</strong>——这正是<a href="../product-intro/gut-testing.html" style="color:var(--accent);text-decoration:underline;">菌群检测</a>的核心价值,检测数据会沉淀到孩子的健康档案中。</p>
 
       <h3>菌群检测能告诉你什么?</h3>
       <ul>

+ 1 - 1
web/articles/gut-microbiome-immune.html

@@ -886,7 +886,7 @@
       <div class="card card-purple">
         <span class="card-icon">🔬</span>
         <h3>① 评估——了解你的菌群状态</h3>
-        <p>养护的第一步是<strong>检测而非猜测</strong>。肠道菌群检测(宏基因组测序或16S rRNA测序)可以提供以下关键指标:</p>
+        <p>养护的第一步是<strong>检测而非猜测</strong>。<a href="../product-intro/gut-testing.html" style="color:var(--accent);text-decoration:underline;">肠道菌群检测(宏基因组测序或16S rRNA测序)</a>可以提供以下关键指标:</p>
         <ul class="list-styled">
           <li><strong>菌群多样性</strong>— 最核心的健康指标,多样性越低提示慢性炎症风险越高</li>
           <li><strong>F/B比值</strong>— 肥胖/代谢综合征的风险提示</li>

BIN
web/articles/hrw-individual-variability-2026.png


+ 1 - 1
web/articles/leaky-gut.html

@@ -236,7 +236,7 @@
 
     <div class="info-card purple">
       <h3>🧪 菌群检测——了解你的肠道生态</h3>
-      <p>修复肠漏的前提是了解导致肠漏的菌群因素。肠道菌群检测(16S rRNA测序)可以评估:</p>
+      <p>修复肠漏的前提是了解导致肠漏的菌群因素。<a href="../product-intro/gut-testing.html" style="color:var(--accent);text-decoration:underline;">肠道菌群检测(16S rRNA测序)</a>可以评估:</p>
       <p>• 菌群多样性——多样性越低,肠漏风险越高</p>
       <p>• 产丁酸菌丰度——丁酸菌不足→肠上皮细胞能量供应不足→屏障受损</p>
       <p>• 条件致病菌丰度——某些致病菌(如克雷伯菌、变形杆菌)直接破坏紧密连接</p>

+ 1 - 1
web/articles/memory-gut.html

@@ -184,7 +184,7 @@
 
     <div class="info-card teal">
       <h3>🧪 菌群检测——了解孩子的"第二基因组"</h3>
-      <p>每个孩子的肠道菌群都是独一无二的——就像指纹一样。通过肠道菌群检测(16S rRNA测序),可以了解孩子肠道菌群的多样性、有益菌和有害菌的比例、产丁酸菌的丰度等信息。基于检测结果,可以制定针对性的饮食和营养方案,而不是"盲补"。</p>
+      <p>每个孩子的肠道菌群都是独一无二的——就像指纹一样。通过<a href="../product-intro/gut-testing.html" style="color:var(--accent);text-decoration:underline;">肠道菌群检测(16S rRNA测序)</a>,可以了解孩子肠道菌群的多样性、有益菌和有害菌的比例、产丁酸菌的丰度等信息。基于检测结果,可以制定针对性的饮食和营养方案,而不是"盲补"。</p>
     </div>
 
     <div class="highlight-box">

+ 1 - 1
web/articles/metabolic-syndrome.html

@@ -847,7 +847,7 @@
         <li>饮食是否高糖高脂、低纤维?(肠道菌群失调→内毒素升高)</li>
         <li>有没有服用抗生素或长期PPI药物?(菌群破坏→炎症因子升高)</li>
       </ul>
-      <p style="margin-top:0.5rem;font-size:0.85rem;color:var(--text-muted);">推荐检测:肠道菌群检测(评估多样性、产丁酸菌丰度、条件致病菌比例)</p>
+      <p style="margin-top:0.5rem;font-size:0.85rem;color:var(--text-muted);">推荐检测:<a href="../product-intro/gut-testing.html" style="color:var(--accent);text-decoration:underline;">肠道菌群检测</a>(评估多样性、产丁酸菌丰度、条件致病菌比例)</p>
     </div>
     <div class="card card-teal">
       <span class="card-icon">🦠</span>

+ 1 - 1
web/articles/pancreatitis.html

@@ -412,7 +412,7 @@
       <p><strong>② 戒酒或限酒:</strong>酒精是胰腺炎的第二大病因。对已发作过胰腺炎的患者,<strong>完全戒酒</strong>是最重要的预防措施。即使少量饮酒也可能诱发复发。</p>
       <p><strong>③ 胆结石管理:</strong>胆源性胰腺炎发作后,建议在出院前或出院后1-4周内行胆囊切除术,可有效预防复发。胆囊切除术后胆源性胰腺炎的复发率几乎为零。</p>
       <p><strong>④ 修复肠道屏障:</strong>肠漏是全身慢性炎症的重要驱动因素。通过膳食纤维(益生元)、谷氨酰胺、锌等营养素修复肠道屏障,降低LPS入血,从源头减少胰腺的炎症暴露。</p>
-      <p><strong>⑤ 菌群检测与调理:</strong>胰腺炎发作后,肠道菌群往往处于失衡状态。通过16S rRNA菌群检测了解菌群状态,针对性补充膳食纤维、调整饮食结构,恢复菌群多样性,可能降低复发风险。</p>
+      <p><strong>⑤ 菌群检测与调理:</strong>胰腺炎发作后,肠道菌群往往处于失衡状态。通过<a href="../product-intro/gut-testing.html" style="color:var(--accent);text-decoration:underline;">16S rRNA菌群检测</a>了解菌群状态,针对性补充膳食纤维、调整饮食结构,恢复菌群多样性,可能降低复发风险。</p>
     </div>
 
     <div class="highlight-box">

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff