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

feat(task): 任务系统升级-行为调度中心前端(动作联动/两阶段/填写式完成)

- api.js 新增 startTask,completeTask 支持 content/contentType
- tasks.vue 今日任务:按 actionType 渲染按钮文案(开始游戏/专注/填写/阅读/报名/购物),locked 灰态+解锁提示,start_required 先开始再完成,require_input 弹填写面板提交内容
- create-task.vue 新增联动动作/两阶段/填写式开关+商品ID配置,重复设置扩展隔天/每月,补全 repeatTypeLabel/actionTypeLabel computed

Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 2 месяцев назад
Родитель
Сommit
11f0e83718
3 измененных файлов с 244 добавлено и 10 удалено
  1. 77 3
      cfc-frontend/pages/tasks/create-task.vue
  2. 161 5
      cfc-frontend/pages/tasks/tasks.vue
  3. 6 2
      cfc-frontend/utils/api.js

+ 77 - 3
cfc-frontend/pages/tasks/create-task.vue

@@ -70,6 +70,34 @@
 <switch :checked="form.needReview" @change="onNeedReviewChange" />
 <switch :checked="form.needReview" @change="onNeedReviewChange" />
 </view>
 </view>
 
 
+<!-- TASK-SYS-248: 联动动作 -->
+<view class="form-item">
+<text class="label">联动动作(可选)</text>
+<picker :range="actionTypes" range-key="label" @change="onActionTypeChange">
+<view class="picker">
+{{ actionTypeLabel || '无(手动完成)' }}
+</view>
+</picker>
+<text class="form-hint">选择后任务按钮将跳转对应功能页</text>
+</view>
+
+<view class="form-item" v-if="form.actionType === 'cart' || form.actionType === 'order'">
+<text class="label">商品ID(购物任务)</text>
+<input class="input" type="number" v-model="form.actionConfig" placeholder="请输入商品ID,如 123" />
+<text class="form-hint">购物任务:点开始自动加购并跳转购物车</text>
+</view>
+
+<view class="form-item">
+<text class="label">需先开始再完成(两阶段)</text>
+<switch :checked="form.startRequired" @change="onStartRequiredChange" />
+<text class="form-hint">小游戏/专注/购物等任务建议开启</text>
+</view>
+
+<view class="form-item">
+<text class="label">需填写内容后完成</text>
+<switch :checked="form.requireInput" @change="onRequireInputChange" />
+</view>
+
 <view class="form-item">
 <view class="form-item">
 <text class="label">预计时长(分钟)</text>
 <text class="label">预计时长(分钟)</text>
 <input class="input" type="number" v-model="form.duration" placeholder="请输入预计完成时长" />
 <input class="input" type="number" v-model="form.duration" placeholder="请输入预计完成时长" />
@@ -97,7 +125,11 @@ return {
           deadline: null,
           deadline: null,
           repeatType: 'none',
           repeatType: 'none',
           needReview: false,
           needReview: false,
-          duration: null
+          duration: null,
+          actionType: '',
+          actionConfig: '',
+          requireInput: false,
+          startRequired: false
         },
         },
         children: [],
         children: [],
         selectedChildIds: [],
         selectedChildIds: [],
@@ -107,13 +139,35 @@ return {
 repeatTypes: [
 repeatTypes: [
 { value: 'none', label: '不重复' },
 { value: 'none', label: '不重复' },
 { value: 'daily', label: '每天' },
 { value: 'daily', label: '每天' },
-{ value: 'weekly', label: '每周' }
+{ value: 'weekly', label: '每周' },
+{ value: 'every_other_day', label: '隔天' },
+{ value: 'monthly', label: '每月' }
+],
+actionTypes: [
+{ value: '', label: '无(手动完成)' },
+{ value: 'minigame', label: '小游戏' },
+{ value: 'focus', label: '专注训练' },
+{ value: 'survey', label: '问卷/测评' },
+{ value: 'article', label: '文章阅读' },
+{ value: 'activity', label: '活动报名' },
+{ value: 'cart', label: '购物(加购)' },
+{ value: 'order', label: '购物(支付/收货)' }
 ],
 ],
 dateTimeRange: [[], [], [], []],
 dateTimeRange: [[], [], [], []],
 dateTimeIndex: [0, 0, 0, 0],
 dateTimeIndex: [0, 0, 0, 0],
 deadlineText: '',
 deadlineText: '',
 submitting: false
 submitting: false
 }
 }
+},
+computed: {
+repeatTypeLabel() {
+const found = this.repeatTypes.find(r => r.value === this.form.repeatType)
+return found ? found.label : ''
+},
+actionTypeLabel() {
+const found = this.actionTypes.find(a => a.value === this.form.actionType)
+return found ? found.label : ''
+}
 },
 },
   onLoad(options) {
   onLoad(options) {
     this.loadChildren(options.memberId)
     this.loadChildren(options.memberId)
@@ -256,6 +310,19 @@ this.form.repeatType = selected.value
 },
 },
 onNeedReviewChange(e) {
 onNeedReviewChange(e) {
 this.form.needReview = e.detail.value
 this.form.needReview = e.detail.value
+},
+onActionTypeChange(e) {
+const selected = this.actionTypes[e.detail.value]
+this.form.actionType = selected.value
+if (selected.value !== 'cart' && selected.value !== 'order') {
+this.form.actionConfig = ''
+}
+},
+onStartRequiredChange(e) {
+this.form.startRequired = e.detail.value
+},
+onRequireInputChange(e) {
+this.form.requireInput = e.detail.value
 },
 },
     async submit() {
     async submit() {
       if (this.submitting) return
       if (this.submitting) return
@@ -287,8 +354,14 @@ this.form.needReview = e.detail.value
         minigameCode: this.form.minigameCode,
         minigameCode: this.form.minigameCode,
         deadline: this.form.deadline.toISOString(),
         deadline: this.form.deadline.toISOString(),
         repeatType: this.form.repeatType,
         repeatType: this.form.repeatType,
+        frequency: this.form.repeatType,
         needReview: this.form.needReview ? 1 : 0,
         needReview: this.form.needReview ? 1 : 0,
-        duration: parseInt(this.form.duration) || 0
+        duration: parseInt(this.form.duration) || 0,
+        // TASK-SYS-248: 行为调度中心字段
+        actionType: this.form.actionType || '',
+        actionConfig: this.form.actionConfig || '',
+        requireInput: this.form.requireInput ? 1 : 0,
+        startRequired: this.form.startRequired ? 1 : 0
       }
       }
 
 
       let successCount = 0
       let successCount = 0
@@ -329,6 +402,7 @@ this.form.needReview = e.detail.value
 .input { height: 80rpx; }
 .input { height: 80rpx; }
 .textarea { height: 150rpx; }
 .textarea { height: 150rpx; }
 .picker { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; color: #666; }
 .picker { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; color: #666; }
+.form-hint { display: block; font-size: 22rpx; color: #999; margin-top: 8rpx; }
 .child-checkbox { display: flex; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f0f0f0; }
 .child-checkbox { display: flex; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f0f0f0; }
 .child-checkbox:last-child { border-bottom: none; }
 .child-checkbox:last-child { border-bottom: none; }
 .child-name { font-size: 28rpx; color: #333; margin-left: 10rpx; }
 .child-name { font-size: 28rpx; color: #333; margin-left: 10rpx; }

+ 161 - 5
cfc-frontend/pages/tasks/tasks.vue

@@ -40,22 +40,30 @@
 
 
     <!-- 今日任务 -->
     <!-- 今日任务 -->
     <view class="task-list" v-if="activeTab === 'today'">
     <view class="task-list" v-if="activeTab === 'today'">
-      <view class="task-item" v-for="task in todayTasks" :key="task.id" :class="{ completed: task.status === 'completed' }">
+      <view class="task-item" v-for="task in todayTasks" :key="task.id" :class="{ completed: task.status === 'completed', locked: task.status === 'locked' }">
         <view class="task-left">
         <view class="task-left">
           <view class="task-title">
           <view class="task-title">
             {{ task.title }}
             {{ task.title }}
             <text v-if="task.memberOnly === 1" class="member-badge">会员任务</text>
             <text v-if="task.memberOnly === 1" class="member-badge">会员任务</text>
             <text v-if="task.category === '小游戏类'" class="minigame-badge">🎮</text>
             <text v-if="task.category === '小游戏类'" class="minigame-badge">🎮</text>
+            <text v-if="task.actionType === 'cart' || task.actionType === 'order'" class="minigame-badge">🛒</text>
+            <text v-if="task.actionType === 'focus'" class="minigame-badge">⏱️</text>
+            <text v-if="task.actionType === 'survey'" class="minigame-badge">📝</text>
+            <text v-if="task.actionType === 'article'" class="minigame-badge">📖</text>
+            <text v-if="task.actionType === 'activity'" class="minigame-badge">🎯</text>
+            <text v-if="task.requireInput === 1" class="minigame-badge">✍️</text>
           </view>
           </view>
           <view class="task-meta">
           <view class="task-meta">
             <text class="points">+{{ task.points }}分</text>
             <text class="points">+{{ task.points }}分</text>
             <text class="deadline">截止 {{ formatTime(task.deadline) }}</text>
             <text class="deadline">截止 {{ formatTime(task.deadline) }}</text>
           </view>
           </view>
+          <text v-if="task.status === 'locked'" class="lock-tip">🔒 需先完成前置任务后解锁</text>
         </view>
         </view>
-        <view class="task-right" v-if="task.status === 'pending'">
-          <button class="btn-complete" @click="completeTask(task)">完成</button>
+        <view class="task-right" v-if="task.status === 'pending' || task.status === 'in_progress'">
+          <button class="btn-complete" @click="handleTaskAction(task)">{{ getActionLabel(task) }}</button>
         </view>
         </view>
-        <view class="completed-badge" v-else>已完成</view>
+        <view class="completed-badge" v-else-if="task.status === 'completed'">已完成</view>
+        <view class="lock-badge" v-else>🔒 未解锁</view>
       </view>
       </view>
       <view class="empty" v-if="!loading && todayTasks.length === 0">
       <view class="empty" v-if="!loading && todayTasks.length === 0">
         <text>暂无今日任务</text>
         <text>暂无今日任务</text>
@@ -101,6 +109,26 @@
       </view>
       </view>
     </view>
     </view>
 
 
+    <!-- 填写式完成弹窗 -->
+    <view class="modal-mask" v-if="showInputModal" @click="closeInputModal">
+      <view class="modal modal-large" @click.stop>
+        <view class="modal-title">完成任务</view>
+        <view class="modal-content">
+          <text class="input-tip">{{ activeInputTask && activeInputTask.title }}</text>
+          <textarea
+            v-model="inputContent"
+            class="input-textarea"
+            placeholder="请填写完成内容(如:今天完成了...)"
+            :maxlength="500"
+          ></textarea>
+        </view>
+        <view class="modal-btns">
+          <button @click="closeInputModal">取消</button>
+          <button class="btn-primary" :disabled="!inputContent" @click="submitWithInput">提交完成</button>
+        </view>
+      </view>
+    </view>
+
     <!-- 创建任务弹窗 -->
     <!-- 创建任务弹窗 -->
     <view class="modal-mask" v-if="showAddModal" @click="showAddModal = false">
     <view class="modal-mask" v-if="showAddModal" @click="showAddModal = false">
       <view class="modal modal-large" @click.stop>
       <view class="modal modal-large" @click.stop>
@@ -155,7 +183,7 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import { getMyPendingTasks, acceptTask as acceptTaskApi, rejectTask as rejectTaskApi, getTodayTasks, completeTask as completeTaskApi, createTask as createTaskApi, getFamilyMemberList, getMyMembership } from '../../utils/api.js'
+import { getMyPendingTasks, acceptTask as acceptTaskApi, rejectTask as rejectTaskApi, getTodayTasks, completeTask as completeTaskApi, startTask as startTaskApi, createTask as createTaskApi, getFamilyMemberList, getMyMembership } from '../../utils/api.js'
 import { parseDate } from '../../utils/format.js'
 import { parseDate } from '../../utils/format.js'
 
 
 export default {
 export default {
@@ -174,6 +202,9 @@ export default {
       showAddModal: false,
       showAddModal: false,
       showRejectModalFlag: false,
       showRejectModalFlag: false,
       rejectTaskId: null,
       rejectTaskId: null,
+      showInputModal: false,
+      activeInputTask: null,
+      inputContent: '',
       newTask: {
       newTask: {
         title: '',
         title: '',
         description: '',
         description: '',
@@ -266,6 +297,98 @@ export default {
         console.error('完成任务失败', e)
         console.error('完成任务失败', e)
       }
       }
     },
     },
+    /** 按钮文案:按 action_type / start_required / require_input 分派 */
+    getActionLabel(task) {
+      if (task.startRequired === 1 && task.status !== 'in_progress') return '开始'
+      if (task.requireInput === 1) return '填写完成'
+      var at = task.actionType
+      if (at === 'minigame') return '开始游戏'
+      if (at === 'focus') return '开始专注'
+      if (at === 'survey') return '去填写'
+      if (at === 'article') return '去阅读'
+      if (at === 'activity') return '去报名'
+      if (at === 'cart' || at === 'order') return '去购物'
+      return '完成'
+    },
+    /** 统一任务动作入口:开始/联动跳转/填写/直接完成 */
+    handleTaskAction(task) {
+      var self = this
+      // 前置锁定任务不可操作
+      if (task.status === 'locked') {
+        uni.showToast({ title: '需先完成前置任务', icon: 'none' })
+        return
+      }
+      // 两阶段:先开始,再按 action_type 跳转联动页面
+      if (task.startRequired === 1 && task.status !== 'in_progress') {
+        uni.showLoading({ title: '开始中...' })
+        startTaskApi(task.id, this.currentMemberId).then(function(res) {
+          uni.hideLoading()
+          if (res && res.code === 200 && res.data) {
+            uni.showToast({ title: '已开始', icon: 'success' })
+            self.navigateByAction(task)
+            self.loadTodayTasks()
+          } else {
+            uni.showToast({ title: (res && res.message) || '开始失败', icon: 'none' })
+          }
+        }).catch(function() {
+          uni.hideLoading()
+          uni.showToast({ title: '开始失败', icon: 'none' })
+        })
+        return
+      }
+      // 填写式完成
+      if (task.requireInput === 1) {
+        this.activeInputTask = task
+        this.inputContent = ''
+        this.showInputModal = true
+        return
+      }
+      // 已开始的两阶段任务或普通任务:直接完成
+      this.completeTask(task)
+    },
+    /** 按 action_type 跳转联动页面 */
+    navigateByAction(task) {
+      var at = task.actionType
+      var taskId = task.id
+      if (at === 'minigame') {
+        var code = 'schulte'
+        try {
+          if (task.actionConfig) {
+            var cfg = JSON.parse(task.actionConfig)
+            if (cfg.gameCode) code = cfg.gameCode
+          }
+        } catch (e) {}
+        uni.navigateTo({ url: '/pages/games/' + code + '?taskId=' + taskId })
+      } else if (at === 'focus') {
+        uni.navigateTo({ url: '/pages/focus/index?taskId=' + taskId })
+      } else if (at === 'survey') {
+        uni.navigateTo({ url: '/pages/assessment/survey-questionnaire?taskId=' + taskId })
+      } else if (at === 'article') {
+        uni.navigateTo({ url: '/pages/article-center/article-detail?taskId=' + taskId })
+      } else if (at === 'activity') {
+        uni.navigateTo({ url: '/pages/activity/activity-list?taskId=' + taskId })
+      } else if (at === 'cart' || at === 'order') {
+        uni.navigateTo({ url: '/pages/shop/cart?taskId=' + taskId })
+      }
+    },
+    closeInputModal() {
+      this.showInputModal = false
+      this.activeInputTask = null
+      this.inputContent = ''
+    },
+    async submitWithInput() {
+      var task = this.activeInputTask
+      if (!task || !this.inputContent) return
+      try {
+        const res = await completeTaskApi(task.id, this.currentMemberId, '', this.inputContent, 'text')
+        uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
+        this.closeInputModal()
+        this.loadTodayTasks()
+      } catch (e) {
+        console.error('提交填写内容失败', e)
+        uni.showToast({ title: '提交失败', icon: 'none' })
+      }
+    },
     acceptTask(task) {
     acceptTask(task) {
       if (task.memberOnly === 1 && !this.isMember) {
       if (task.memberOnly === 1 && !this.isMember) {
         uni.showModal({
         uni.showModal({
@@ -556,6 +679,39 @@ export default {
   font-size: 26rpx;
   font-size: 26rpx;
 }
 }
 
 
+.lock-badge {
+  color: #B0B0B0;
+  font-size: 26rpx;
+}
+
+.lock-tip {
+  display: block;
+  margin-top: 8rpx;
+  font-size: 22rpx;
+  color: #B0B0B0;
+}
+
+.task-item.locked {
+  opacity: 0.6;
+}
+
+.input-tip {
+  display: block;
+  font-size: 26rpx;
+  color: #666;
+  margin-bottom: 16rpx;
+}
+
+.input-textarea {
+  width: 100%;
+  height: 240rpx;
+  background: #F7F7F7;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  font-size: 26rpx;
+  box-sizing: border-box;
+}
+
 .minigame-badge {
 .minigame-badge {
   font-size: 28rpx;
   font-size: 28rpx;
   margin-left: 10rpx;
   margin-left: 10rpx;

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

@@ -422,8 +422,12 @@ export const rejectTask = (taskId) => {
   return request(`/api/tasks/${taskId}/review`, 'POST', { approved: false })
   return request(`/api/tasks/${taskId}/review`, 'POST', { approved: false })
 }
 }
 
 
-export const completeTask = (taskId, memberId, photoUrl) => {
-  return request(`/api/tasks/${taskId}/complete`, 'POST', { memberId, photoUrl })
+export const completeTask = (taskId, memberId, photoUrl, content, contentType) => {
+  return request(`/api/tasks/${taskId}/complete`, 'POST', { memberId, photoUrl, content, contentType })
+}
+
+export const startTask = (taskId, memberId) => {
+  return request(`/api/tasks/${taskId}/start`, 'POST', { memberId })
 }
 }
 
 
 export const reviewTask = (taskId, data) => {
 export const reviewTask = (taskId, data) => {