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

feat: 阅读/喝水/购买打卡完整链路(含任务回写)

- tasks.vue: navigateByAction 修复存量 bug(article/cart/order 不再直跳 completeTask)
- tasks.vue: article 类型跳转带上 taskId + articleId
- tasks.vue: cart/order 按 actionConfig.isExternal 分流(系统商品→购物车/外部商品→拍照上传页)
- article-detail.vue: onLoad 接收 taskId,submitQuiz 答完题后调 completeTask
- water-index.vue: onLoad 接收 taskId,打卡成功即完成任务
- HealthWaterController.java: 补任务回写(一次性完成/每日进度累计)+ 积分发放
- HealthWaterRecord.java: 加 taskId 字段
- schema.sql + DatabaseInitializer 迁移384: water_record.task_id
- pages.json: 注册 task-upload-purchase 分包页面
- task-upload-purchase.vue: 新增外部平台商品拍照上传打卡页
- shop/cart → checkout → payment: taskId 链路透传,支付成功后调 completeTask
liaoxg 5 дней назад
Родитель
Сommit
e723420bba

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

@@ -3097,6 +3097,9 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移383: health_status 添加 signed_at 列(手签名时间;问卷内容变更后置空,需重新签署)
         ensureColumn("health_status", "signed_at", "DATETIME DEFAULT NULL COMMENT '手签名时间(问卷变更后置空,需重新签署)'");
+
+        // 迁移384: water_record 添加 task_id 列(喝水打卡任务回写,与 health_meal_record.task_id 同款)
+        ensureColumn("water_record", "task_id", "BIGINT NULL COMMENT '关联任务ID(喝水打卡任务回写)'");
     }
 
     private void migrateHealthDimensionsTable() {

+ 60 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthWaterController.java

@@ -2,8 +2,11 @@ package com.etotem.cfc.controller;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.FamilyMember;
 import com.etotem.cfc.entity.HealthWaterRecord;
+import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.mapper.HealthWaterRecordMapper;
+import com.etotem.cfc.mapper.TaskMapper;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -12,9 +15,13 @@ import javax.annotation.Resource;
 import java.util.Date;
 import java.util.List;
 import java.util.Map;
+import com.etotem.cfc.service.PointsService;
+import com.etotem.cfc.service.TaskService;
 import com.etotem.cfc.util.SortUtil;
 import com.etotem.cfc.util.ParamUtils;
+import lombok.extern.slf4j.Slf4j;
 
+@Slf4j
 @Tag(name = "喝水打卡")
 @RestController
 @RequestMapping("/api/health/water")
@@ -23,6 +30,18 @@ public class HealthWaterController {
     @Resource
     private HealthWaterRecordMapper healthWaterRecordMapper;
 
+    @Resource
+    private TaskMapper taskMapper;
+
+    @Resource
+    private com.etotem.cfc.mapper.FamilyMemberMapper familyMemberMapper;
+
+    @Resource
+    private PointsService pointsService;
+
+    @Resource
+    private TaskService taskService;
+
     @Operation(summary = "获取喝水打卡列表")
     @PostMapping("/list")
     public Result<List<HealthWaterRecord>> list(@RequestBody Map<String, Object> params,
@@ -42,8 +61,49 @@ public class HealthWaterController {
         if (record.getMemberId() == null) {
             record.setMemberId(currentMemberId);
         }
+        if (record.getMemberId() == null) return Result.error("memberId不能为空");
         record.setCreatedAt(new Date());
         healthWaterRecordMapper.insert(record);
+
+        // 任务回写挂钩
+        if (record.getTaskId() != null) {
+            try {
+                Task linkedTask = taskMapper.selectById(record.getTaskId());
+                if (linkedTask != null) {
+                    if (linkedTask.getIsDailyProgress() != null && linkedTask.getIsDailyProgress() == 1) {
+                        FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
+                        if (fm != null && fm.getUserId() != null) {
+                            taskService.updateProgress(fm.getUserId(), "WATER", 1);
+                        }
+                    } else {
+                        taskService.completeTask(
+                                record.getTaskId(),
+                                record.getMemberId(),
+                                "",
+                                "喝水打卡",
+                                "text");
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("喝水打卡任务回写失败: {}", e.getMessage());
+            }
+        } else {
+            try {
+                FamilyMember fm = familyMemberMapper.selectById(record.getMemberId());
+                if (fm != null && fm.getUserId() != null) {
+                    taskService.updateProgress(fm.getUserId(), "WATER", 1);
+                }
+            } catch (Exception e) {
+                log.warn("喝水打卡进度回写失败: {}", e.getMessage());
+            }
+        }
+
+        // 积分发放
+        try {
+            pointsService.awardCheckinPoints(record.getMemberId(), 5, "喝水打卡");
+        } catch (Exception e) {
+            log.warn("喝水打卡积分发放失败: {}", e.getMessage());
+        }
         return Result.success(record);
     }
 }

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/HealthWaterRecord.java

@@ -14,6 +14,7 @@ public class HealthWaterRecord implements Serializable {
     private Long id;
     private Long memberId;
     private Long checkinId;
+    private Long taskId;
     private Integer amountMl;
     private String waterType;
     private Date drunkAt;

+ 1 - 0
cfc-backend/src/main/resources/schema.sql

@@ -3315,6 +3315,7 @@ CREATE TABLE IF NOT EXISTS water_record (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     member_id BIGINT NULL COMMENT '家庭成员ID',
     checkin_id BIGINT COMMENT '关联打卡ID',
+    task_id BIGINT COMMENT '关联任务ID(喝水打卡任务回写)',
     amount_ml INT COMMENT '饮水量(毫升)',
     water_type VARCHAR(32) COMMENT '类型: plain/sparkling/tea/other',
     drunk_at DATETIME COMMENT '饮水时间',

+ 6 - 0
cfc-frontend/pages.json

@@ -300,6 +300,12 @@
           "style": {
             "navigationBarTitleText": "每日任务"
           }
+        },
+        {
+          "path": "task-upload-purchase",
+          "style": {
+            "navigationBarTitleText": "外部商品打卡"
+          }
         }
       ]
     },

+ 13 - 2
cfc-frontend/pages/article-center/article-detail.vue

@@ -132,7 +132,7 @@
 </template>
 
 <script>
-import { getArticleDetail, getArticleAudio, getArticleRelatedProducts, getRelatedArticles, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz } from '@/utils/api.js'
+import { getArticleDetail, getArticleAudio, getArticleRelatedProducts, getRelatedArticles, reportReadingTime, getShareQrCode, completeArticleRead, generateQuiz, submitQuiz, completeTask } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import ContentSharePoster from '@/components/ContentSharePoster.vue'
 import DimensionProducts from '@/components/DimensionProducts.vue'
@@ -159,7 +159,8 @@ export default {
       // 答题
       showQuiz: false, quizQuestions: [], quizAnswers: [], showResult: false, quizResult: {}, quizRecordId: null, currentQuizIndex: 0,
       mascotList: [{ icon: '🐶', name: '浠宝' }, { icon: '🐼', name: '福宝' }],
-      mascotIcon: '🐶', mascotName: '浠宝'
+      mascotIcon: '🐶', mascotName: '浠宝',
+      taskId: null
     }
   },
   computed: {
@@ -195,6 +196,7 @@ export default {
     }
     if (aid) { this.articleId = aid; this.loadDetail(aid) }
     else { this.error = true; this.errorMsg = '参数错误'; this.loading = false }
+    this.taskId = options && options.taskId ? parseInt(options.taskId) : null
     this.currentChildId = uni.getStorageSync('currentChildId') || null
     this.isLogin = !!uni.getStorageSync('token')
   },
@@ -447,6 +449,15 @@ export default {
           this.quizResult = res.data || {}
           this.showQuiz = false
           this.showResult = true
+          // 有关联任务:答完题即完成打卡任务
+          if (this.taskId && memberId) {
+            try {
+              var cr = await completeTask(this.taskId, parseInt(memberId), '', '阅读+答题完成', 'text')
+              if (cr && cr.data) {
+                uni.showToast({ title: '任务已完成', icon: 'success' })
+              }
+            } catch (e) { /* ignore */ }
+          }
         }
       } catch (e) {}
     },

+ 22 - 2
cfc-frontend/pages/health/water-index.vue

@@ -106,6 +106,7 @@ export default {
   data() {
     return {
       memberId: null,
+      taskId: null,
       submitting: false,
       history: [],
       quickAmounts: [
@@ -123,12 +124,13 @@ export default {
       }
     }
   },
-  onLoad: function() {
+  onLoad: function(options) {
     var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
     // child 角色:memberId 由后端从 JWT 获取 currentMemberId,前端无需传
     if (role !== 'child') {
       this.memberId = uni.getStorageSync('currentChildId') || null
     }
+    this.taskId = options && options.taskId ? parseInt(options.taskId) : null
     this.loadHistory()
   },
   methods: {
@@ -176,6 +178,7 @@ export default {
         method: 'POST',
         data: {
           memberId: self.memberId,
+          taskId: self.taskId || undefined,
           amountMl: amount,
           waterType: self.form.waterType || 'plain'
         },
@@ -183,10 +186,27 @@ export default {
         success: function(res) {
           self.submitting = false
           if (res.data && res.data.code === 200) {
-            uni.showToast({ title: '打卡成功', icon: 'success' })
+            var msg = self.taskId ? '打卡成功,任务已完成' : '打卡成功'
+            uni.showToast({ title: msg, icon: 'success' })
             self.form.amountMl = 200
             self.form.waterType = 'plain'
             self.loadHistory()
+            // 有任务关联:打卡即完成任务
+            if (self.taskId && self.memberId) {
+              uni.request({
+                url: config.API_BASE_URL + '/api/tasks/' + self.taskId + '/complete',
+                url: config.API_BASE_URL + '/api/tasks/' + self.taskId + '/complete',
+                method: 'POST',
+                data: { memberId: self.memberId, photoUrl: '', content: '饮水打卡完成', contentType: 'text' },
+                header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
+                success: function(cr) {
+                  if (cr.data && cr.data.code === 200) {
+                    uni.showToast({ title: '任务已完成', icon: 'success' })
+                  }
+                },
+                fail: function() {}
+              })
+            }
           } else {
             var msg = (res.data && res.data.message) || '打卡失败'
             uni.showToast({ title: msg, icon: 'none' })

+ 5 - 2
cfc-frontend/pages/shop/cart/cart.vue

@@ -150,6 +150,7 @@ export default {
       loading: false,
       refreshing: false,
       userId: null,
+      taskId: null,
       isMember: false,
       giftProgress: null,  // 赠会员累计进度
       giftPromo: null      // 送会员一口价促销信息
@@ -251,8 +252,10 @@ export default {
       return this.giftProgress && this.giftProgress.granted === 1
     }
   },
-  onLoad() {
+  onLoad(options) {
     this.userId = uni.getStorageSync('userId')
+    // 购买任务打卡:透传 taskId 至结算/支付页
+    this.taskId = options && options.taskId ? parseInt(options.taskId) : null
     this.checkMemberStatus()
     this.loadCart()
     this.loadGiftProgress()
@@ -417,7 +420,7 @@ export default {
       }
       uni.setStorageSync('checkoutItems', selected)
       uni.setStorageSync('checkoutGiftPromo', this.giftPromo)
-      uni.navigateTo({ url: '/pages/shop/checkout/checkout' })
+      uni.navigateTo({ url: '/pages/shop/checkout/checkout' + (this.taskId ? '?taskId=' + this.taskId : '') })
     },
     formatPriceWithSymbol(price) {
       if (price === null || price === undefined) return '0.00'

+ 5 - 2
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -531,7 +531,8 @@ export default {
       giftItemIds: [],
       giftProgress: null,   // 赠会员累计进度
       discountInfo: null,
-      giftPromo: null       // 送会员一口价促销信息
+      giftPromo: null,       // 送会员一口价促销信息
+      taskId: null          // 购买任务打卡:透传至支付页
     }
   },
   computed: {
@@ -669,6 +670,8 @@ export default {
     }
     this.checkMemberStatus()
     this.loadDefaultAddress()
+    // 购买任务打卡:接受 taskId 并透传至支付页
+    this.taskId = options && options.taskId ? parseInt(options.taskId) : null
     this.loadRequiredFields()
     this.loadCoupons()
     this.loadExchangeableCoupons()
@@ -1091,7 +1094,7 @@ export default {
             var pointsUsed = order.pointsUsed || 0
             setTimeout(function() {
               uni.navigateTo({
-                url: '/pages/shop/payment/payment?orderNo=' + order.orderNo + '&amount=' + moneyAmount + '&pointsUsed=' + pointsUsed
+                url: '/pages/shop/payment/payment?orderNo=' + order.orderNo + '&amount=' + moneyAmount + '&pointsUsed=' + pointsUsed + (this.taskId ? '&taskId=' + this.taskId : '')
               })
             }, 1000)
           } else {

+ 23 - 1
cfc-frontend/pages/shop/payment/payment.vue

@@ -106,7 +106,8 @@ export default {
       pointsUsed: 0,
       isMember: false,
       showUpgradeCard: false,
-      familyYearlyPrice: '365'
+      familyYearlyPrice: '365',
+      taskId: null
     }
   },
   computed: {
@@ -128,6 +129,8 @@ export default {
     if (options.pointsUsed) {
       this.pointsUsed = parseInt(options.pointsUsed)
     }
+    // 购买任务打卡(系统商品:支付成功即完成任务;外部平台商品走独立拍照上传页)
+    this.taskId = options && options.taskId ? parseInt(options.taskId) : null
     this.startCountdown()
   },
   onUnload() {
@@ -166,6 +169,25 @@ export default {
     },
     handlePaymentSuccess() {
       var that = this
+      // 购买任务打卡:系统商品支付成功即完成任务
+      //(外部平台商品走 /pages/tasks/task-upload-purchase 拍照上传,不经此页)
+      if (that.taskId) {
+        var memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || ''
+        if (memberId) {
+          uni.request({
+            url: config.api('/api/tasks/' + that.taskId + '/complete'),
+            method: 'POST',
+            data: { memberId: memberId, photoUrl: '', content: '购买商品打卡完成', contentType: 'text' },
+            header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+            success: function(cr) {
+              if (cr.data && cr.data.code === 200) {
+                uni.showToast({ title: '任务已完成', icon: 'success' })
+              }
+            },
+            fail: function() {}
+          })
+        }
+      }
       if (!this.isMember) {
         setTimeout(function() {
           that.showUpgradeCard = true

+ 194 - 0
cfc-frontend/pages/tasks/task-upload-purchase.vue

@@ -0,0 +1,194 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-left" @tap="goBack">
+        <text class="nav-icon">‹</text>
+        <text class="nav-title">外部商品打卡</text>
+      </view>
+    </view>
+
+    <scroll-view class="content" scroll-y>
+      <!-- 任务说明 -->
+      <view class="card">
+        <view class="card-header">
+          <text class="card-icon">🛒</text>
+          <text class="card-title">{{ taskTitle }}</text>
+        </view>
+        <view class="card-body">
+          <text class="task-desc">按以下步骤完成本次外部平台商品购买打卡:</text>
+          <view class="step-list">
+            <view class="step-item">
+              <text class="step-num">1</text>
+              <text class="step-text">前往外部平台完成商品购买</text>
+            </view>
+            <view class="step-item">
+              <text class="step-num">2</text>
+              <text class="step-text">收到商品后,拍摄实物照片</text>
+            </view>
+            <view class="step-item">
+              <text class="step-num">3</text>
+              <text class="step-text">上传照片,提交完成打卡任务</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 拍照上传 -->
+      <view class="card">
+        <view class="card-header">
+          <text class="card-icon">📸</text>
+          <text class="card-title">上传商品实物照片</text>
+        </view>
+        <view class="card-body">
+          <view class="upload-area" @tap="choosePhoto">
+            <image v-if="photoUrl" :src="photoUrl" mode="aspectFill" class="photo-preview" />
+            <view v-else class="upload-placeholder">
+              <text class="upload-icon">📷</text>
+              <text class="upload-text">拍照 / 相册选择商品实物照片</text>
+            </view>
+          </view>
+          <view class="submit-btn" :class="{ submitting: submitting }" @tap="submit">
+            <text>{{ submitting ? '提交中...' : '✓ 提交完成打卡' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="bottom-spacer"></view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import config from '../../config'
+
+export default {
+  data() {
+    return {
+      taskId: null,
+      memberId: null,
+      taskTitle: '',
+      photoUrl: '',
+      submitting: false
+    }
+  },
+  onLoad(options) {
+    this.taskId = options && options.taskId ? parseInt(options.taskId) : null
+    this.taskTitle = options && options.title ? decodeURIComponent(options.title) : '外部平台商品任务'
+    var role = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
+    if (role !== 'child') {
+      this.memberId = uni.getStorageSync('currentChildId') || uni.getStorageSync('currentMemberId') || uni.getStorageSync('userId') || null
+    } else {
+      this.memberId = uni.getStorageSync('currentMemberId') || null
+    }
+  },
+  methods: {
+    goBack() { uni.navigateBack() },
+    choosePhoto: function() {
+      var self = this
+      uni.chooseImage({
+        count: 1,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: function(res) {
+          self.photoUrl = res.tempFilePaths[0]
+        }
+      })
+    },
+    uploadFile: function(filePath) {
+      var self = this
+      return new Promise(function(resolve, reject) {
+        uni.uploadFile({
+          url: config.API_BASE_URL + '/api/media/upload',
+          filePath: filePath,
+          name: 'file',
+          header: { 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+          success: function(res) {
+            try {
+              var data = JSON.parse(res.data)
+              resolve(data.data || data.url || '')
+            } catch (e) {
+              resolve(res.data || '')
+            }
+          },
+          fail: function() { reject() }
+        })
+      })
+    },
+    submit: async function() {
+      var self = this
+      if (self.submitting) return
+      if (!self.photoUrl) { uni.showToast({ title: '请先上传商品实物照片', icon: 'none' }); return }
+      if (!self.memberId) { uni.showToast({ title: '成员ID无效', icon: 'none' }); return }
+      self.submitting = true
+      var photoUrl = ''
+      try { photoUrl = await self.uploadFile(self.photoUrl) } catch (e) {}
+      try {
+        const res = await new Promise(function(resolve, reject) {
+          uni.request({
+            url: config.API_BASE_URL + '/api/tasks/' + self.taskId + '/complete',
+            method: 'POST',
+            data: { memberId: self.memberId, photoUrl: photoUrl, content: '外部平台商品实物已上传', contentType: 'image' },
+            header: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + uni.getStorageSync('token') },
+            success: function(r) { r.statusCode && r.statusCode !== 200 ? reject(r) : resolve(r) },
+            fail: function() { reject() }
+          })
+        })
+        if (res.data && res.data.code === 200) {
+          uni.showToast({ title: '任务已完成', icon: 'success' })
+          setTimeout(function() { uni.navigateBack() }, 800)
+        } else {
+          uni.showToast({ title: (res.data && res.data.message) || '提交失败', icon: 'none' })
+        }
+      } catch (e) {
+        uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+      }
+      self.submitting = false
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #F9FAFB; }
+.nav-bar { height: 88rpx; padding: 24rpx 24rpx 0; display: flex; align-items: flex-end; }
+.nav-left { display: flex; align-items: center; gap: 10rpx; }
+.nav-icon { font-size: 48rpx; color: #333; line-height: 1; }
+.nav-title { font-size: 34rpx; font-weight: 600; color: #333; }
+.content { padding-bottom: 40rpx; }
+.card { margin: 16rpx 20rpx; background: #fff; border-radius: 20rpx; overflow: hidden; box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05); }
+.card-header { display: flex; align-items: center; gap: 10rpx; padding: 20rpx 24rpx 8rpx; border-bottom: 1rpx solid #f5f5f5; background: linear-gradient(90deg, #FFF7ED, #fff); }
+.card-icon { font-size: 32rpx; }
+.card-title { font-size: 30rpx; font-weight: 600; color: #333; }
+.card-body { padding: 24rpx; }
+.task-desc { font-size: 26rpx; color: #6B7280; line-height: 1.6; display: block; margin-bottom: 20rpx; }
+.step-list { display: flex; flex-direction: column; gap: 16rpx; }
+.step-item { display: flex; align-items: center; gap: 16rpx; }
+.step-num {
+  width: 44rpx; height: 44rpx; border-radius: 50%;
+  background: linear-gradient(135deg, #FF8C42, #F97316);
+  color: #fff; font-size: 24rpx; font-weight: 600;
+  display: flex; align-items: center; justify-content: center;
+  flex-shrink: 0;
+}
+.step-text { font-size: 26rpx; color: #374151; }
+.upload-area {
+  width: 100%; height: 320rpx;
+  border: 2rpx dashed #D1D5DB; border-radius: 16rpx;
+  display: flex; align-items: center; justify-content: center;
+  background: #F9FAFB; overflow: hidden;
+  margin-bottom: 24rpx;
+}
+.photo-preview { width: 100%; height: 100%; }
+.upload-placeholder { display: flex; flex-direction: column; align-items: center; gap: 12rpx; }
+.upload-icon { font-size: 72rpx; }
+.upload-text { font-size: 26rpx; color: #9CA3AF; }
+.submit-btn {
+  background: linear-gradient(135deg, #FF8C42, #F97316);
+  color: #fff; text-align: center; padding: 22rpx 0;
+  border-radius: 14rpx; font-size: 30rpx; font-weight: 600;
+  box-shadow: 0 6rpx 20rpx rgba(249,115,22,0.3);
+}
+.submit-btn:active { transform: scale(0.98); }
+.submitting { opacity: 0.6; }
+.bottom-spacer { height: 40rpx; }
+</style>

+ 19 - 6
cfc-frontend/pages/tasks/tasks.vue

@@ -428,10 +428,10 @@ export default {
         this.showInputModal = true
         return
       }
-      // 有联动页面的任务(actionType 明确指向某一功能页):一律跳转执行,
-      // 由各联动页/打卡页提交时回写(一次性任务直接完成 / 每日进度任务累计进度)
-      var at = task.actionType
-      if (at && ['minigame', 'focus', 'survey', 'article', 'activity', 'cart', 'order', 'diet', 'sleep', 'exercise'].indexOf(at) >= 0) {
+      // 打卡/联动类任务(含每日进度型打卡任务):跳转联动页面执行,
+      // 打卡提交后由后端回写(一次性任务直接完成 / 每日任务累计进度)
+      var linkedTypes = ['minigame', 'focus', 'survey', 'article', 'activity', 'cart', 'order', 'diet', 'sleep', 'exercise']
+      if (linkedTypes.indexOf(task.actionType) >= 0) {
         this.navigateByAction(task)
         return
       }
@@ -452,11 +452,24 @@ export default {
       } else if (at === 'survey') {
         uni.navigateTo({ url: '/pages/survey/index?taskId=' + taskId })
       } else if (at === 'article') {
-        uni.navigateTo({ url: '/pages/article-center/index?taskId=' + taskId })
+        var cfg = {}
+        try { cfg = typeof task.actionConfig === 'string' ? JSON.parse(task.actionConfig || '{}') : (task.actionConfig || {}) } catch (e) {}
+        var articleId = cfg.articleId || task.sourceId
+        uni.navigateTo({ url: '/pages/article-center/article-detail?taskId=' + taskId + (articleId ? '&id=' + articleId : '') })
       } else if (at === 'activity') {
         uni.navigateTo({ url: '/pages/activity/list?taskId=' + taskId })
       } else if (at === 'cart' || at === 'order') {
-        uni.navigateTo({ url: '/pages/shop/cart?taskId=' + taskId })
+        var cfg = {}
+        try { cfg = typeof task.actionConfig === 'string' ? JSON.parse(task.actionConfig || '{}') : (task.actionConfig || {}) } catch (e) {}
+        var isExternal = cfg.isExternal === 1 || cfg.isExternal === '1'
+        if (isExternal) {
+          // 外部平台商品:拍照上传打卡页(标题经 URL 传递,避免额外请求)
+          var tTitle = encodeURIComponent(task.title || '外部平台商品')
+          uni.navigateTo({ url: '/pages/tasks/task-upload-purchase?taskId=' + taskId + '&title=' + tTitle })
+        } else {
+          // 系统商品:走购物车 → 支付 → 自动完成
+          uni.navigateTo({ url: '/pages/shop/cart?taskId=' + taskId })
+        }
       } else if (at === 'diet') {
         uni.navigateTo({ url: '/pages/growth/diet-checkin?taskId=' + taskId })
       } else if (at === 'sleep') {