Jelajahi Sumber

feat: 添加任务完成表扬通知(后端触发、配置字段、模板、管理端API/UI、H5设置页)

Xiaogang Liao 1 hari lalu
induk
melakukan
fbad0d85b5

+ 24 - 9
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -11503,25 +11503,39 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
         log.info("迁移345: health_plans.total_days 已确保存在");
     }
 
+    /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */
     /** 迁移346: 未打卡任务提醒通知模板(原有种子仅在空表时插入,此处单独幂等补种) */
     private void seedTaskReminderTemplate() {
         try {
             Integer cnt = jdbcTemplate.queryForObject(
                     "SELECT COUNT(*) FROM notification_templates WHERE type = ?",
                     Integer.class, "task_reminder");
-            if (cnt != null && cnt > 0) {
-                log.info("迁移346: task_reminder 通知模板已存在,跳过");
-                return;
+            if (cnt != null && cnt == 0) {
+                jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
+                        "task_reminder",
+                        "任务待完成提醒",
+                        "任务「{title}」{status},完成后记得打卡,可获得积分与 CF 值奖励。",
+                        "in_app,sms");
+                log.info("迁移346: task_reminder 通知模板已插入");
             }
-            jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
-                    "task_reminder",
-                    "任务待完成提醒",
-                    "任务「{title}」{status},完成后记得打卡,可获得积分与 CF 值奖励。",
-                    "in_app,sms");
-            log.info("迁移346: task_reminder 通知模板已插入");
         } catch (Exception e) {
             log.warn("迁移346: 插入 task_reminder 通知模板失败: " + e.getMessage());
         }
+        try {
+            Integer c2 = jdbcTemplate.queryForObject(
+                    "SELECT COUNT(*) FROM notification_templates WHERE type = ?",
+                    Integer.class, "task_completion_praise");
+            if (c2 != null && c2 == 0) {
+                jdbcTemplate.update("INSERT INTO notification_templates (type, title, content, channels) VALUES (?,?,?,?)",
+                        "task_completion_praise",
+                        "任务完成,太棒了!",
+                        "太棒了!任务「{title}」已完成,继续保持,加油~",
+                        "in_app,wechat");
+                log.info("迁移346+: task_completion_praise 通知模板已插入");
+            }
+        } catch (Exception e) {
+            log.warn("迁移346+: 插入 task_completion_praise 通知模板失败: " + e.getMessage());
+        }
     }
 
     /**
@@ -11807,6 +11821,7 @@ log.info("迁移298: 已为无 openid 的 child 账号补齐 family_members 记
                     "UNIQUE KEY uk_user (user_id)" +
                     ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='任务提醒配置表'");
             log.info("迁移353: 已创建 task_reminder_config 表");
+            try { ensureColumn("task_reminder_config", "completion_praise_enabled", "TINYINT DEFAULT 0 COMMENT '任务正常完成时是否发送表扬鼓励消息 1=启用 0=停用'"); ensureColumn("task_reminder_config", "completion_praise_channels", "VARCHAR(100) DEFAULT 'wechat,in_app' COMMENT '完成表扬渠道(逗号分隔)'"); } catch (Exception ex) { log.warn("迁移353补充列失败: {}", ex.getMessage()); }
         } catch (Exception e) {
             log.warn("迁移353: 创建 task_reminder_config 表失败: {}", e.getMessage());
         }

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

@@ -21,6 +21,10 @@ public class TaskReminderConfigDTO implements Serializable {
     private Integer enabled;
 
     /** 各渠道开通状态(供配置页展示"未开通") */
+    private Integer completionPraiseEnabled;
+
+    private List<String> completionPraiseChannels;
+
     private List<ChannelInfo> availableChannels;
 
     @Data

+ 6 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/TaskReminderConfig.java

@@ -32,5 +32,11 @@ public class TaskReminderConfig implements Serializable {
 
     private Date createdAt;
 
+    /** 任务正常完成时是否发送表扬鼓励消息 1=启用 0=停用 */
+    private Integer completionPraiseEnabled;
+
+    /** 完成表扬渠道(逗号分隔): in_app/wechat/sms/phone/email */
+    private String completionPraiseChannels;
+
     private Date updatedAt;
 }

+ 24 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskReminderConfigService.java

@@ -48,6 +48,8 @@ public class TaskReminderConfigService {
             dto.setTimingMode("after_timeout");
             dto.setOffsetMinutes(60);
             dto.setEnabled(1);
+            dto.setCompletionPraiseEnabled(0);
+            dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList("wechat", "in_app")));
         } else {
             dto.setChannels(entity.getChannels() == null || entity.getChannels().isEmpty()
                     ? new ArrayList<>(Arrays.asList("wechat"))
@@ -55,6 +57,13 @@ public class TaskReminderConfigService {
             dto.setTimingMode(entity.getTimingMode());
             dto.setOffsetMinutes(entity.getOffsetMinutes());
             dto.setEnabled(entity.getEnabled());
+            dto.setCompletionPraiseEnabled(entity.getCompletionPraiseEnabled() == null ? 0 : entity.getCompletionPraiseEnabled());
+            String pc = entity.getCompletionPraiseChannels();
+            if (pc == null || pc.isEmpty()) {
+                dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList("wechat", "in_app")));
+            } else {
+                dto.setCompletionPraiseChannels(new ArrayList<>(Arrays.asList(pc.split(","))));
+            }
         }
         dto.setAvailableChannels(buildChannelStatus());
         return dto;
@@ -94,6 +103,21 @@ public class TaskReminderConfigService {
         entity.setTimingMode(timingMode);
         entity.setOffsetMinutes(offset);
         entity.setEnabled(dto.getEnabled() != null && dto.getEnabled() == 0 ? 0 : 1);
+        List<String> praiseChannels = dto.getCompletionPraiseChannels() == null ? new ArrayList<>() : dto.getCompletionPraiseChannels();
+        List<String> cleanedPraise = new ArrayList<>();
+        for (String ch : praiseChannels) {
+            if (ch == null) continue;
+            String c = ch.trim();
+            if (SUPPORTED_CHANNELS.contains(c) && !cleanedPraise.contains(c)) {
+                cleanedPraise.add(c);
+            }
+        }
+        if (cleanedPraise.isEmpty()) {
+            cleanedPraise.add("in_app");
+            cleanedPraise.add("wechat");
+        }
+        entity.setCompletionPraiseEnabled(dto.getCompletionPraiseEnabled() != null && dto.getCompletionPraiseEnabled() == 1 ? 1 : 0);
+        entity.setCompletionPraiseChannels(String.join(",", cleanedPraise));
         entity.setUpdatedAt(new Date());
         if (isNew) {
             taskReminderConfigMapper.insert(entity);

+ 30 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TaskService.java

@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
 import java.util.*;
 import com.etotem.cfc.util.DimensionCodes;
 import com.etotem.cfc.util.SortUtil;
+import com.etotem.cfc.service.NotificationDispatchService;
 
 @Slf4j
 @Service
@@ -30,6 +31,12 @@ public class TaskService implements TaskServiceInterface {
     @Resource
     private TaskMapper taskMapper;
 
+    @Resource
+    private NotificationDispatchService notificationDispatchService;
+
+    @Resource
+    private TaskReminderConfigService taskReminderConfigService;
+
     @Resource
     private TaskExecutionMapper taskExecutionMapper;
 
@@ -640,6 +647,18 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         result.put("pointsEarned", pointsEarned);
         result.put("newBalance", child != null ? child.getTotalPoints() : 0);
         result.put("needReview", false);
+        try {
+            com.etotem.cfc.entity.TaskReminderConfig cfg = taskReminderConfigService.loadAllConfigs().get(task.getExecutorId());
+            if (cfg != null && Integer.valueOf(1).equals(cfg.getCompletionPraiseEnabled())) {
+                java.util.HashMap<String, Object> vars = new java.util.HashMap<String, Object>();
+                vars.put("title", task.getTitle());
+                String chans = cfg.getCompletionPraiseChannels();
+                if (chans == null || chans.isEmpty()) chans = "wechat,in_app";
+                notificationDispatchService.sendByTemplate("task_completion_praise", task.getExecutorId(), vars, "task", task.getId(),
+                        "任务完成,太棒了!",
+                        "太棒了!任务「" + task.getTitle() + "」已完成,继续保持,加油~");
+            }
+        } catch (Exception e) {}
         return result;
     }
 
@@ -814,6 +833,17 @@ public List<Task> getTodayTasks(Long memberId, String dimensionCode) {
         try { FamilyMember child = familyMemberMapper.selectById(task.getFamilyMemberId()); if (child != null) { this.completeOnboardingTask(child.getUserId(), "FIRST_TASK"); } } catch (Exception e) { /* onboarding trigger failure should not block task completion */ }
     result.put("newBalance", user.getTotalPoints());
     result.put("needReview", task.getNeedReview() == 1);
+    try {
+        Long receiver = userId;
+        com.etotem.cfc.entity.TaskReminderConfig cfg = taskReminderConfigService.loadAllConfigs().get(receiver);
+        if (cfg != null && Integer.valueOf(1).equals(cfg.getCompletionPraiseEnabled())) {
+            java.util.HashMap<String, Object> vars = new java.util.HashMap<String, Object>();
+            vars.put("title", task.getTitle());
+            notificationDispatchService.sendByTemplate("task_completion_praise", receiver, vars, "task", task.getId(),
+                    "任务完成,太棒了!",
+                    "太棒了!任务「" + task.getTitle() + "」已完成,继续保持,加油~");
+        }
+    } catch (Exception e) {}
 
     return result;
   }

+ 47 - 206
cfc-frontend/pages/reminder-config/index.vue

@@ -57,6 +57,31 @@
         <text class="offset-hint" v-if="offsetHint">{{ offsetHint }}</text>
       </view>
 
+      <!-- 完成表扬开关 -->
+      <view class="cfg-card">
+        <view class="cfg-card-header">
+          <text class="cfg-card-title">任务完成鼓励</text>
+          <text class="cfg-switch" :class="{ 'cfg-switch-on': completionPraiseEnabled === 1 }" @tap="togglePraiseEnabled">
+            <text class="cfg-switch-text">{{ completionPraiseEnabled === 1 ? '已开启' : '已关闭' }}</text>
+          </text>
+        </view>
+        <text class="cfg-card-desc">任务正常完成时,发送表扬鼓励消息</text>
+        <view class="channel-list" v-if="completionPraiseEnabled === 1" style="margin-top:20rpx">
+          <view class="channel-item" v-for="ch in channels" :key="'p-'+ch.type"
+            :class="{
+              'channel-selected': isPraiseSelected(ch.type),
+              'channel-disabled': !ch.available
+            }"
+            @tap="togglePraiseChannel(ch)">
+            <view class="channel-check" v-if="isPraiseSelected(ch.type) && ch.available">✓</view>
+            <view class="channel-info">
+              <text class="channel-name">{{ ch.label }}</text>
+              <text class="channel-reason" v-if="!ch.available">{{ ch.reason || '未开通' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
       <!-- 站内消息兜底说明 -->
       <view class="tip-card">
         <text class="tip-text">💡 站内消息提醒始终开启,作为兜底通道不随以上设置关闭</text>
@@ -85,6 +110,8 @@ export default {
       selectedChannels: [],
       timingMode: 'after_timeout',
       offsetMinutes: 60,
+      completionPraiseEnabled: 0,
+      selectedPraiseChannels: ['wechat', 'in_app'],
       _loaded: false
     }
   },
@@ -124,6 +151,8 @@ export default {
         self.channels = Array.isArray(d.availableChannels) ? d.availableChannels.filter(function(c) {
           return c && c.type !== 'in_app'
         }) : []
+        self.completionPraiseEnabled = d.completionPraiseEnabled === 1 ? 1 : 0
+        self.selectedPraiseChannels = Array.isArray(d.completionPraiseChannels) ? d.completionPraiseChannels.slice() : ['wechat', 'in_app']
       }
     }).catch(function() {
       self.loading = false
@@ -134,9 +163,15 @@ export default {
     toggleEnabled: function() {
       this.enabled = this.enabled === 1 ? 0 : 1
     },
+    togglePraiseEnabled: function() {
+      this.completionPraiseEnabled = this.completionPraiseEnabled === 1 ? 0 : 1
+    },
     isSelected: function(type) {
       return (this.selectedChannels || []).indexOf(type) >= 0
     },
+    isPraiseSelected: function(type) {
+      return (this.selectedPraiseChannels || []).indexOf(type) >= 0
+    },
     toggleChannel: function(ch) {
       if (!ch.available) return
       var idx = this.selectedChannels.indexOf(ch.type)
@@ -146,6 +181,15 @@ export default {
         this.selectedChannels.push(ch.type)
       }
     },
+    togglePraiseChannel: function(ch) {
+      if (!ch.available) return
+      var idx = this.selectedPraiseChannels.indexOf(ch.type)
+      if (idx >= 0) {
+        this.selectedPraiseChannels.splice(idx, 1)
+      } else {
+        this.selectedPraiseChannels.push(ch.type)
+      }
+    },
     switchTiming: function(mode) {
       this.timingMode = mode
     },
@@ -171,7 +215,9 @@ export default {
         channels: this.selectedChannels.slice(),
         timingMode: this.timingMode,
         offsetMinutes: offset,
-        enabled: this.enabled === 1 ? 1 : 0
+        enabled: this.enabled === 1 ? 1 : 0,
+        completionPraiseEnabled: this.completionPraiseEnabled === 1 ? 1 : 0,
+        completionPraiseChannels: this.selectedPraiseChannels.slice()
       }).then(function(res) {
         self.saving = false
         if (res && res.code === 200) {
@@ -190,208 +236,3 @@ export default {
   }
 }
 </script>
-
-<style scoped>
-.reminder-config-page {
-  box-sizing: border-box;
-  min-height: 100vh;
-  background: #F5F9FC;
-  overflow: hidden;
-}
-.content {
-  padding: 30rpx;
-  box-sizing: border-box;
-  width: 100%;
-  overflow: hidden;
-}
-.cfg-card {
-  background-color: #FFFFFF;
-  border-radius: 24rpx;
-  padding: 30rpx;
-  margin-bottom: 30rpx;
-  box-shadow: 0 4rpx 16rpx rgba(91, 155, 213, 0.08);
-}
-.cfg-card-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-}
-.cfg-card-title {
-  font-size: 30rpx;
-  font-weight: 600;
-  color: #1E293B;
-}
-.cfg-card-desc {
-  display: block;
-  margin-top: 12rpx;
-  font-size: 24rpx;
-  color: #94A3B8;
-  line-height: 1.6;
-}
-.cfg-switch {
-  padding: 10rpx 28rpx;
-  border-radius: 999rpx;
-  font-size: 26rpx;
-  background: #E2E8F0;
-  color: #64748B;
-}
-.cfg-switch-on {
-  background: linear-gradient(135deg, #5B9BD5, #8FC5E8);
-  color: #FFFFFF;
-  font-weight: 600;
-}
-.channel-list {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 20rpx;
-  margin-top: 24rpx;
-}
-.channel-item {
-  box-sizing: border-box;
-  position: relative;
-  display: flex;
-  align-items: center;
-  padding: 16rpx 24rpx;
-  border: 2rpx solid #E2E8F0;
-  border-radius: 20rpx;
-  background: #FFFFFF;
-  min-width: 200rpx;
-}
-.channel-selected {
-  border-color: #5B9BD5;
-  background: #5B9BD5;
-}
-.channel-disabled {
-  opacity: 0.55;
-}
-.channel-check {
-  position: absolute;
-  top: -8rpx;
-  right: -8rpx;
-  width: 32rpx;
-  height: 32rpx;
-  border-radius: 50%;
-  background-color: #FFFFFF;
-  color: #5B9BD5;
-  font-size: 20rpx;
-  font-weight: 900;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  z-index: 2;
-  box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.1);
-}
-.channel-info {
-  display: flex;
-  flex-direction: column;
-  gap: 6rpx;
-}
-.channel-name {
-  font-size: 26rpx;
-  color: #1E293B;
-  font-weight: 500;
-}
-.channel-selected .channel-name {
-  color: #FFFFFF;
-}
-.channel-reason {
-  font-size: 20rpx;
-  color: #94A3B8;
-}
-.channel-tip {
-  width: 100%;
-  margin-top: 16rpx;
-}
-.channel-tip-text {
-  font-size: 24rpx;
-  color: #5B9BD5;
-}
-.timing-list {
-  display: flex;
-  gap: 20rpx;
-  margin-top: 24rpx;
-}
-.timing-item {
-  flex: 1;
-  box-sizing: border-box;
-  padding: 24rpx;
-  border: 2rpx solid #E2E8F0;
-  border-radius: 20rpx;
-  display: flex;
-  flex-direction: column;
-  gap: 8rpx;
-}
-.timing-active {
-  border-color: #5B9BD5;
-  background: #F0F7FF;
-}
-.timing-name {
-  font-size: 28rpx;
-  font-weight: 600;
-  color: #1E293B;
-}
-.timing-active .timing-name {
-  color: #3A7CC4;
-}
-.timing-desc {
-  font-size: 22rpx;
-  color: #94A3B8;
-}
-.offset-row {
-  display: flex;
-  align-items: center;
-  margin-top: 30rpx;
-  padding: 20rpx 24rpx;
-  background: #F8FAFC;
-  border-radius: 16rpx;
-}
-.offset-label {
-  font-size: 26rpx;
-  color: #1E293B;
-  margin-right: 20rpx;
-}
-.offset-input {
-  flex: 1;
-  font-size: 28rpx;
-  color: #1E293B;
-}
-.offset-unit {
-  font-size: 24rpx;
-  color: #94A3B8;
-  margin-left: 12rpx;
-}
-.offset-hint {
-  display: block;
-  margin-top: 12rpx;
-  font-size: 22rpx;
-  color: #94A3B8;
-}
-.tip-card {
-  background: #D6EAF8;
-  border-radius: 16rpx;
-  padding: 24rpx 30rpx;
-  margin-bottom: 30rpx;
-}
-.tip-text {
-  font-size: 24rpx;
-  color: #3A7CC4;
-  line-height: 1.6;
-}
-.save-area {
-  padding: 40rpx 30rpx;
-}
-.save-btn {
-  background: linear-gradient(135deg, #5B9BD5, #3A7CC4);
-  color: #FFFFFF;
-  text-align: center;
-  padding: 28rpx 0;
-  border-radius: 999rpx;
-  font-size: 30rpx;
-  font-weight: 600;
-  box-shadow: 0 6rpx 20rpx rgba(91, 155, 213, 0.3);
-}
-.save-btn:active {
-  opacity: 0.9;
-  transform: scale(0.98);
-}
-</style>

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

@@ -3405,3 +3405,12 @@ export const getIndicatorChangeRanking = (params) => request('/api/indicator/cha
 export const getIndicatorQuickLookup = (params) => request('/api/indicator/quick-lookup', 'POST', params || {})
 /** 指标关联查询:{ subjectId, definitionId }(本期仅封装,展示二期) */
 export const getIndicatorCorrelationList = (params) => request('/api/indicator/correlation/list', 'POST', params || {})
+
+// 管理端:任务提醒配置
+export function getReminderConfigByUser(userId) {
+  return request(/api/admin/task/reminder-config/get?userId= + userId, POST, {})
+}
+
+export function saveReminderConfigByUser(data) {
+  return request(/api/admin/task/reminder-config/save, POST, data)
+}

+ 18 - 0
cfc-web/src/api/taskReminderConfig.js

@@ -0,0 +1,18 @@
+import request from '@/utils/request'
+
+// 管理端:获取指定用户的任务提醒配置
+export function getUserReminderConfig(userId) {
+  return request({
+    url: '/api/admin/task/reminder-config/get?userId=' + userId,
+    method: 'post'
+  })
+}
+
+// 管理端:保存指定用户的任务提醒配置
+export function saveUserReminderConfig(data) {
+  return request({
+    url: '/api/admin/task/reminder-config/save',
+    method: 'post',
+    data
+  })
+}

+ 87 - 0
cfc-web/src/views/Users.vue

@@ -134,6 +134,7 @@
                 更多<i class="el-icon-arrow-down el-icon--right"></i>
               </el-button>
               <el-dropdown-menu slot="dropdown">
+                <el-dropdown-item command="reminderConfig" icon="el-icon-setting">提醒设置</el-dropdown-item>
                 <el-dropdown-item command="resetPassword" icon="el-icon-key">重置密码</el-dropdown-item>
                 <el-dropdown-item command="delete" icon="el-icon-delete">删除</el-dropdown-item>
               </el-dropdown-menu>
@@ -146,6 +147,40 @@
       </div>
 
       <!-- 分页 -->
+          <el-dialog title="任务提醒配置" :visible.sync="reminderDialogVisible" width="600px">
+      <el-form label-width="120px">
+        <el-form-item label="启用">
+          <el-switch v-model="reminderConfig.enabled" :active-value="1" :inactive-value="0"></el-switch>
+        </el-form-item>
+        <el-form-item label="提醒渠道">
+          <el-checkbox-group v-model="reminderConfig.channels">
+            <el-checkbox v-for="ch in (reminderConfig.availableChannels||[])" :key="ch.type" :label="ch.type" :disabled="ch.type==='in_app'">{{ ch.label }}</el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+        <el-form-item label="提醒时机">
+          <el-select v-model="reminderConfig.timingMode" placeholder="请选择">
+            <el-option label="截止前提醒" value="before_deadline"></el-option>
+            <el-option label="超时后提醒" value="after_timeout"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="偏移分钟">
+          <el-input-number v-model="reminderConfig.offsetMinutes" :min="1" :max="1440"></el-input-number>
+        </el-form-item>
+        <el-form-item label="完成表扬">
+          <el-switch v-model="reminderConfig.completionPraiseEnabled" :active-value="1" :inactive-value="0"></el-switch>
+        </el-form-item>
+        <el-form-item label="表扬渠道" v-if="reminderConfig.completionPraiseEnabled===1">
+          <el-checkbox-group v-model="reminderConfig.completionPraiseChannels">
+            <el-checkbox v-for="ch in (reminderConfig.availableChannels||[])" :key="'p'+ch.type" :label="ch.type" :disabled="ch.type==='in_app'">{{ ch.label }}</el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button @click="reminderDialogVisible = false">取 消</el-button>
+        <el-button type="primary" @click="saveReminderConfig">保 存</el-button>
+      </span>
+    </el-dialog>
+
       <el-pagination
         style="margin-top: 20px; text-align: right"
         @size-change="handleSizeChange"
@@ -424,6 +459,58 @@ const res = await getUserList(params)
         }
       })
     },
+    handleReminderConfig(row) {
+      this.currentUser = row;
+      this.reminderConfig = {
+        channels: ["wechat"],
+        timingMode: "after_timeout",
+        offsetMinutes: 60,
+        enabled: 1,
+        completionPraiseEnabled: 0,
+        completionPraiseChannels: ["wechat", "in_app"]
+      };
+      this.reminderDialogVisible = true;
+      getUserReminderConfig(row.id).then(res => {
+        if (res.code === 200 && res.data) {
+          this.reminderConfig = {
+            channels: res.data.channels || ["wechat"],
+            timingMode: res.data.timingMode || "after_timeout",
+            offsetMinutes: res.data.offsetMinutes || 60,
+            enabled: res.data.enabled === 0 ? 0 : 1,
+            completionPraiseEnabled: res.data.completionPraiseEnabled === 1 ? 1 : 0,
+            completionPraiseChannels: res.data.completionPraiseChannels || ["wechat", "in_app"],
+            availableChannels: res.data.availableChannels || []
+          };
+        }
+      });
+    },
+    saveReminderConfig() {
+      if (!this.reminderConfig.channels || this.reminderConfig.channels.length === 0) {
+        this.$message.warning("请至少选择一个提醒渠道");
+        return;
+      }
+      if (this.reminderConfig.offsetMinutes < 1 || this.reminderConfig.offsetMinutes > 1440) {
+        this.$message.warning("偏移分钟需在 1-1440 之间");
+        return;
+      }
+      const data = {
+        userId: this.currentUser.id,
+        channels: this.reminderConfig.channels,
+        timingMode: this.reminderConfig.timingMode,
+        offsetMinutes: this.reminderConfig.offsetMinutes,
+        enabled: this.reminderConfig.enabled,
+        completionPraiseEnabled: this.reminderConfig.completionPraiseEnabled,
+        completionPraiseChannels: this.reminderConfig.completionPraiseChannels
+      };
+      saveUserReminderConfig(data).then(res => {
+        if (res.code === 200) {
+          this.$message.success("保存成功");
+          this.reminderDialogVisible = false;
+        } else {
+          this.$message.error(res.message || "保存失败");
+        }
+      });
+    },
     handleDelete(row) {
       this.$confirm(`确定要删除用户 "${row.nickname}" 吗?`, '提示', {
         confirmButtonText: '确定',