Przeglądaj źródła

feat(TASK-004): 完善任务重复设置功能

- 添加截止时间精确到分钟设置(TASK-003)
- 实现每日/每周重复任务设置(TASK-004)
- 创建RepeatTaskGenerator定时任务,每天凌晨5点生成重复任务实例
- Task实体添加templateId和isTemplate字段追踪模板关系
- 前端create-task.vue添加日期时间选择器和重复类型选择
- 添加数据库迁移20-21
User 5 miesięcy temu
rodzic
commit
85444f6dd1

+ 16 - 0
zxyj-backend/src/main/java/com/zxyj/config/DatabaseInitializer.java

@@ -705,6 +705,22 @@ log.info("已添加expired列到points_log表");
 // 列已存在,忽略错误
 }
 
+// 迁移20: tasks表添加template_id字段(TASK-004: 重复任务模板追踪)
+try {
+jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN template_id BIGINT COMMENT \'模板任务ID\'");
+log.info("已添加template_id列到tasks表");
+} catch (Exception e) {
+// 列已存在,忽略错误
+}
+
+// 迁移21: tasks表添加is_template字段(TASK-004: 标记模板任务)
+try {
+jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN is_template TINYINT DEFAULT 0 COMMENT \'是否为模板任务\'");
+log.info("已添加is_template列到tasks表");
+} catch (Exception e) {
+// 列已存在,忽略错误
+}
+
 log.info("数据库迁移完成");
     }
 

+ 7 - 0
zxyj-backend/src/main/java/com/zxyj/entity/Task.java

@@ -36,6 +36,7 @@ private Integer points;
 
 private Date deadline;
 
+// TASK-004: 重复类型 (none/daily/weekly)
 private String repeatType;
 
 private String category;
@@ -55,6 +56,12 @@ private String bonusRule;
 // POINT-009: 积分有效期天数(0表示永久有效)
 private Integer pointsExpireDays;
 
+// TASK-004: 模板任务ID(重复任务生成的实例指向原模板)
+private Long templateId;
+
+// TASK-004: 是否为模板任务(模板任务不会被完成,只用于生成实例)
+private Integer isTemplate;
+
 private Date createdAt;
 
 private Date updatedAt;

+ 33 - 23
zxyj-backend/src/main/java/com/zxyj/service/TaskService.java

@@ -67,25 +67,32 @@ public class TaskService {
         task.setTitle(dto.getTitle());
         task.setDescription(dto.getDescription());
         task.setPoints(dto.getPoints() != null ? dto.getPoints() : 2);
-        task.setDeadline(dto.getDeadline());
-        task.setRepeatType(dto.getRepeatType() != null ? dto.getRepeatType() : "none");
-        task.setCategory(dto.getCategory());
-        
-        // 家长给孩子安排任务需要审核,孩子给家长安排任务也需要审核
-        if ("child".equals(executorType)) {
-            task.setNeedReview(dto.getNeedReview() != null ? dto.getNeedReview() : 0);
-        } else {
-            // 孩子给家长安排任务,默认需要审核
-            task.setNeedReview(dto.getNeedReview() != null ? dto.getNeedReview() : 1);
-        }
-        
-        task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCategory() : 0);
-        task.setStatus("pending");
-        task.setCreatedAt(new Date());
-        task.setUpdatedAt(new Date());
-        taskMapper.insert(task);
-        return task.getId();
-    }
+task.setDeadline(dto.getDeadline());
+task.setRepeatType(dto.getRepeatType() != null ? dto.getRepeatType() : "none");
+task.setCategory(dto.getCategory());
+
+// TASK-004: 如果是重复任务,标记为模板
+if (!"none".equals(task.getRepeatType())) {
+task.setIsTemplate(1); // 模板任务
+} else {
+task.setIsTemplate(0);
+}
+
+// 家长给孩子安排任务需要审核,孩子给家长安排任务也需要审核
+if ("child".equals(executorType)) {
+task.setNeedReview(dto.getNeedReview() != null ? dto.getNeedReview() : 0);
+} else {
+// 孩子给家长安排任务,默认需要审核
+task.setNeedReview(dto.getNeedReview() != null ? dto.getNeedReview() : 1);
+}
+
+task.setReviewByCategory(dto.getReviewByCategory() != null ? dto.getReviewByCategory() : 0);
+task.setStatus("pending");
+task.setCreatedAt(new Date());
+task.setUpdatedAt(new Date());
+taskMapper.insert(task);
+return task.getId();
+}
 
     public List<Task> getTodayTasks(Long childId) {
         Calendar calendar = Calendar.getInstance();
@@ -142,7 +149,7 @@ if (isEarly || isLateGrace) {
 pointsEarned += EARLY_BONUS;
 } else {
             // 超时,检查是否开启扣分
-            pointsEarned = calculateLatePenalty(task, child);
+            pointsEarned = calculateLatePenalty(task, child, childId);
         }
 }
 
@@ -491,10 +498,13 @@ return null;
 }
 
 /**
-* 计算超时扣分
-*/
+ * 计算超时扣分
+ */
 private int calculateLatePenalty(Task task, Child child, Long childId) {
-int points = task.getPoints();
+    if (child == null) {
+        return 0;
+    }
+    int points = task.getPoints();
 
 if (child.getPenaltyEnabled() != null && child.getPenaltyEnabled() == 1) {
 // 检查今日已扣分数

+ 162 - 0
zxyj-backend/src/main/java/com/zxyj/task/RepeatTaskGenerator.java

@@ -0,0 +1,162 @@
+package com.zxyj.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zxyj.entity.Task;
+import com.zxyj.mapper.TaskMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * TASK-004: 重复任务定时生成器
+ * 每天凌晨自动生成每日/每周重复任务
+ */
+@Slf4j
+@Component
+public class RepeatTaskGenerator {
+
+    @Autowired
+    private TaskMapper taskMapper;
+
+    /**
+     * 每天凌晨5点执行,生成当天的重复任务
+     */
+    @Scheduled(cron = "0 0 5 * * ?")
+    public void generateDailyTasks() {
+        log.info("开始生成重复任务...");
+        
+        try {
+            Date now = new Date();
+            Calendar todayCal = Calendar.getInstance();
+            todayCal.set(Calendar.HOUR_OF_DAY, 0);
+            todayCal.set(Calendar.MINUTE, 0);
+            todayCal.set(Calendar.SECOND, 0);
+            todayCal.set(Calendar.MILLISECOND, 0);
+            Date today = todayCal.getTime();
+            
+            // 1. 处理每日重复任务
+            List<Task> dailyTasks = taskMapper.selectList(
+                new LambdaQueryWrapper<Task>()
+                    .eq(Task::getRepeatType, "daily")
+                    .eq(Task::getStatus, "pending")
+            );
+            
+            for (Task template : dailyTasks) {
+                // 检查今天是否已存在该模板的任务
+                if (!hasTaskForDate(template, today)) {
+                    createTaskFromTemplate(template, today);
+                }
+            }
+            
+            log.info("每日重复任务生成完成,处理{}个模板", dailyTasks.size());
+            
+            // 2. 处理每周重复任务(今天是周几?)
+            int dayOfWeek = todayCal.get(Calendar.DAY_OF_WEEK);
+            // Calendar.SUNDAY = 1, MONDAY = 2, ... SATURDAY = 7
+            // 转换为周一=1 ... 周日=7
+            int weekday = dayOfWeek == 1 ? 7 : dayOfWeek - 1;
+            
+            // 获取每周重复的任务模板(存储在category或用其他方式)
+            // 这里假设原始模板任务的截止时间就是它应该在的星期几
+            List<Task> weeklyTasks = taskMapper.selectList(
+                new LambdaQueryWrapper<Task>()
+                    .eq(Task::getRepeatType, "weekly")
+                    .eq(Task::getStatus, "pending")
+            );
+            
+            for (Task template : weeklyTasks) {
+                // 检查模板的截止时间是周几
+                Calendar templateCal = Calendar.getInstance();
+                templateCal.setTime(template.getDeadline());
+                int templateDayOfWeek = templateCal.get(Calendar.DAY_OF_WEEK);
+                int templateWeekday = templateDayOfWeek == 1 ? 7 : templateDayOfWeek - 1;
+                
+                // 如果今天是该任务的执行日
+                if (weekday == templateWeekday) {
+                    if (!hasTaskForDate(template, today)) {
+                        createTaskFromTemplate(template, today);
+                    }
+                }
+            }
+            
+            log.info("每周重复任务生成完成,处理{}个模板", weeklyTasks.size());
+            
+        } catch (Exception e) {
+            log.error("生成重复任务失败", e);
+        }
+    }
+    
+    /**
+     * 检查指定日期是否已存在该模板的任务实例
+     */
+    private boolean hasTaskForDate(Task template, Date date) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        cal.set(Calendar.HOUR_OF_DAY, 23);
+        cal.set(Calendar.MINUTE, 59);
+        cal.set(Calendar.SECOND, 59);
+        Date endOfDay = cal.getTime();
+        
+        cal.set(Calendar.HOUR_OF_DAY, 0);
+        cal.set(Calendar.MINUTE, 0);
+        cal.set(Calendar.SECOND, 0);
+        Date startOfDay = cal.getTime();
+        
+        Long count = taskMapper.selectCount(
+            new LambdaQueryWrapper<Task>()
+                .eq(Task::getTitle, template.getTitle())
+                .eq(Task::getChildId, template.getChildId())
+                .eq(Task::getFamilyId, template.getFamilyId())
+                .ge(Task::getDeadline, startOfDay)
+                .le(Task::getDeadline, endOfDay)
+        );
+        
+        return count != null && count > 0;
+    }
+    
+/**
+* 从模板创建新任务实例
+*/
+private void createTaskFromTemplate(Task template, Date targetDate) {
+Task newTask = new Task();
+newTask.setFamilyId(template.getFamilyId());
+newTask.setCreatorId(template.getCreatorId());
+newTask.setExecutorType(template.getExecutorType());
+newTask.setExecutorId(template.getExecutorId());
+newTask.setChildId(template.getChildId());
+newTask.setTitle(template.getTitle());
+newTask.setDescription(template.getDescription());
+newTask.setPoints(template.getPoints());
+newTask.setCategory(template.getCategory());
+newTask.setNeedReview(template.getNeedReview());
+newTask.setReviewByCategory(template.getReviewByCategory());
+newTask.setBonusRule(template.getBonusRule());
+newTask.setPointsExpireDays(template.getPointsExpireDays());
+newTask.setTemplateId(template.getId()); // 指向模板
+newTask.setIsTemplate(0); // 这是实例,不是模板
+
+// 设置新的截止时间(保持原有时分,更新日期)
+Calendar newDeadline = Calendar.getInstance();
+newDeadline.setTime(targetDate);
+
+Calendar templateTime = Calendar.getInstance();
+templateTime.setTime(template.getDeadline());
+
+newDeadline.set(Calendar.HOUR_OF_DAY, templateTime.get(Calendar.HOUR_OF_DAY));
+newDeadline.set(Calendar.MINUTE, templateTime.get(Calendar.MINUTE));
+
+newTask.setDeadline(newDeadline.getTime());
+newTask.setRepeatType("none"); // 生成的实例不重复
+newTask.setStatus("pending");
+newTask.setCreatedAt(new Date());
+newTask.setUpdatedAt(new Date());
+
+taskMapper.insert(newTask);
+log.info("已生成重复任务: {} (截止: {})", newTask.getTitle(), newTask.getDeadline());
+}
+}

+ 6 - 1
zxyj-backend/src/main/resources/schema.sql

@@ -63,6 +63,8 @@ CREATE TABLE IF NOT EXISTS tasks (
     family_id BIGINT NOT NULL,
     creator_id BIGINT NOT NULL,
     child_id BIGINT NOT NULL,
+    executor_type VARCHAR(20) COMMENT '执行者类型: child/parent',
+    executor_id BIGINT COMMENT '执行者ID',
     title VARCHAR(200) NOT NULL,
     description VARCHAR(500),
     points TINYINT DEFAULT 2 COMMENT '任务积分1-10',
@@ -73,11 +75,14 @@ CREATE TABLE IF NOT EXISTS tasks (
     review_by_category TINYINT DEFAULT 0 COMMENT '是否按分类审核',
     status ENUM('pending', 'completed', 'overdue', 'cancelled') DEFAULT 'pending',
     completed_at DATETIME,
+    bonus_rule JSON COMMENT '积分奖励规则',
+    points_expire_days INT DEFAULT 0 COMMENT '积分有效期天数',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     INDEX idx_child_id_deadline (child_id, deadline),
     INDEX idx_status (status),
-    INDEX idx_category (category)
+    INDEX idx_category (category),
+    INDEX idx_executor (executor_type, executor_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
 -- 积分流水表

+ 20 - 8
zxyj-frontend/pages/index/parent-index.vue

@@ -14,8 +14,8 @@
       <view class="children-list">
         <view 
           class="child-card" 
-          v-for="member in familyMembers" 
-          :key="member.id"
+          v-for="(member, index) in familyMembers" 
+          :key="member && member.id ? (member.role + '-' + member.id) : index"
           @click="selectMember(member)"
           @longpress="handleMemberLongPress(member)"
         >
@@ -103,7 +103,8 @@ export default {
     const lastVisitPage = uni.getStorageSync('lastVisitPage')
     const currentRole = uni.getStorageSync('currentRole')
     
-    // 如果当前是孩子模式,且是从孩子端进入的任务页
+    // 只有从孩子端进入任务页后返回,才跳转到孩子首页
+    // 登录后首次进入首页不应触发
     if (role === 'child' && lastVisitPage === '/pages/index/child-index') {
       // 清除标记并跳转到孩子首页
       uni.removeStorageSync('lastVisitPage')
@@ -111,6 +112,11 @@ export default {
       return
     }
     
+    // 确保角色是家长
+    if (role !== 'parent') {
+      uni.setStorageSync('role', 'parent')
+    }
+    
     if (uni.getStorageSync('token')) {
       this.loadData()
     }
@@ -129,16 +135,21 @@ export default {
         const userInfo = uni.getStorageSync('userInfo')
         this.nickname = userInfo?.nickname || ''
         
-        // 获取家庭所有成员(家长+孩子)
+        // 获取家庭所有成员(家长+孩子),排除自己
         try {
+          const currentUserId = uni.getStorageSync('userId')
           const membersRes = await getFamilyMembers()
           const data = membersRes.data || {}
-          // 合并家长和孩子为成员列表
-          const parents = (data.parents || []).map(p => ({ ...p, role: 'parent' }))
-          const children = (data.children || []).map(c => ({ ...c, role: 'child' }))
+          // 合并家长和孩子为成员列表,排除当前用户
+          const parents = (data.parents || [])
+            .filter(p => p && p.id && p.id !== currentUserId)
+            .map(p => ({ ...p, role: 'parent' }))
+          const children = (data.children || [])
+            .filter(c => c && c.id)
+            .map(c => ({ ...c, role: 'child' }))
           this.familyMembers = [...parents, ...children]
         } catch(e) {
-          console.log('获取家庭成员失败,使用孩子列表', e)
+          console.log('获取家庭成员失败', e)
           this.familyMembers = []
         }
         
@@ -154,6 +165,7 @@ export default {
       }
     },
     selectMember(member) {
+      if (!member) return
       if (member.role === 'child') {
         // 直接切换到孩子模式
         this.switchToChildMode(member)

+ 218 - 104
zxyj-frontend/pages/tasks/create-task.vue

@@ -1,113 +1,227 @@
 <template>
-  <view class="container">
-    <view class="form-container">
-      <view class="form-item">
-        <text class="label">任务名称</text>
-        <input class="input" v-model="form.title" placeholder="请输入任务名称" />
-      </view>
-      
-      <view class="form-item">
-        <text class="label">任务描述</text>
-        <textarea class="textarea" v-model="form.description" placeholder="请输入任务描述" />
-      </view>
-      
-      <view class="form-item">
-        <text class="label">选择孩子</text>
-        <picker :range="children" range-key="nickname" @change="onChildChange">
-          <view class="picker">
-            {{ selectedChild ? selectedChild.nickname : '请选择孩子' }}
-          </view>
-        </picker>
-      </view>
-      
-      <view class="form-item">
-        <text class="label">积分</text>
-        <input class="input" type="number" v-model="form.points" placeholder="请输入积分" />
-      </view>
-      
-      <view class="form-item">
-        <text class="label">分类</text>
-        <picker :range="categories" @change="onCategoryChange">
-          <view class="picker">
-            {{ form.category || '请选择分类' }}
-          </view>
-        </picker>
-      </view>
-      
-      <view class="form-item">
-        <text class="label">需要审核</text>
-        <switch :checked="form.needReview" @change="form.needReview = !form.needReview" />
-      </view>
-      
-      <button class="btn-primary" @click="submit">创建任务</button>
-    </view>
-  </view>
+<view class="container">
+<view class="form-container">
+<view class="form-item">
+<text class="label">任务名称</text>
+<input class="input" v-model="form.title" placeholder="请输入任务名称" />
+</view>
+
+<view class="form-item">
+<text class="label">任务描述</text>
+<textarea class="textarea" v-model="form.description" placeholder="请输入任务描述" />
+</view>
+
+<view class="form-item">
+<text class="label">选择孩子</text>
+<picker :range="children" range-key="nickname" @change="onChildChange">
+<view class="picker">
+{{ selectedChild ? selectedChild.nickname : '请选择孩子' }}
+</view>
+</picker>
+</view>
+
+<view class="form-item">
+<text class="label">积分</text>
+<input class="input" type="number" v-model="form.points" placeholder="请输入积分 (1-10)" />
+</view>
+
+<view class="form-item">
+<text class="label">分类</text>
+<picker :range="categories" @change="onCategoryChange">
+<view class="picker">
+{{ form.category || '请选择分类' }}
+</view>
+</picker>
+</view>
+
+<!-- TASK-003: 截止时间设置 -->
+<view class="form-item">
+<text class="label">截止时间</text>
+<picker mode="multiSelector" :range="dateTimeRange" :value="dateTimeIndex" @change="onDateTimeChange" @columnchange="onColumnChange">
+<view class="picker">
+{{ deadlineText || '请选择截止时间' }}
+</view>
+</picker>
+</view>
+
+<!-- TASK-004: 重复设置 -->
+<view class="form-item">
+<text class="label">重复设置</text>
+<picker :range="repeatTypes" range-key="label" @change="onRepeatChange">
+<view class="picker">
+{{ repeatTypeLabel || '不重复' }}
+</view>
+</picker>
+</view>
+
+<view class="form-item">
+<text class="label">需要审核</text>
+<switch :checked="form.needReview" @change="onNeedReviewChange" />
+</view>
+
+<button class="btn-primary" @click="submit">创建任务</button>
+</view>
+</view>
 </template>
 
 <script>
 import { getChildren, createTask } from '../../utils/api.js'
 
 export default {
-  data() {
-    return {
-      form: {
-        title: '',
-        description: '',
-        childId: '',
-        points: 2,
-        category: '',
-        needReview: false
-      },
-      children: [],
-      selectedChild: null,
-      categories: ['学习', '生活', '运动', '其他']
-    }
-  },
-  onLoad() {
-    this.loadChildren()
-  },
-  methods: {
-    async loadChildren() {
-      try {
-        const res = await getChildren()
-        this.children = res.data || []
-      } catch (e) {
-        console.error(e)
-      }
-    },
-    onChildChange(e) {
-      this.selectedChild = this.children[e.detail.value]
-      this.form.childId = this.selectedChild.id
-    },
-    onCategoryChange(e) {
-      this.form.category = this.categories[e.detail.value]
-    },
-    async submit() {
-      if (!this.form.title) {
-        uni.showToast({ title: '请输入任务名称', icon: 'none' })
-        return
-      }
-      if (!this.form.childId) {
-        uni.showToast({ title: '请选择孩子', icon: 'none' })
-        return
-      }
-      
-      try {
-        await createTask({
-          title: this.form.title,
-          description: this.form.description,
-          childId: this.form.childId,
-          points: parseInt(this.form.points) || 2,
-          category: this.form.category || '其他',
-          needReview: this.form.needReview ? 1 : 0
-        })
-        uni.showToast({ title: '创建成功', icon: 'success' })
-        setTimeout(() => uni.navigateBack(), 1500)
-      } catch (e) {
-        uni.showToast({ title: '创建失败', icon: 'none' })
-      }
-    }
-  }
+data() {
+return {
+form: {
+title: '',
+description: '',
+childId: '',
+points: 2,
+category: '',
+deadline: null,
+repeatType: 'none',
+needReview: false
+},
+children: [],
+selectedChild: null,
+categories: ['学习类', '生活类', '运动类', '其他'],
+repeatTypes: [
+{ value: 'none', label: '不重复' },
+{ value: 'daily', label: '每天' },
+{ value: 'weekly', label: '每周' }
+],
+dateTimeRange: [[], [], [], []],
+dateTimeIndex: [0, 0, 0, 0],
+deadlineText: ''
+}
+},
+onLoad() {
+this.loadChildren()
+this.initDateTimePicker()
+},
+methods: {
+async loadChildren() {
+try {
+const res = await getChildren()
+this.children = res.data || []
+} catch (e) {
+console.error(e)
+}
+},
+initDateTimePicker() {
+// 初始化日期时间选择器
+const now = new Date()
+const year = now.getFullYear()
+const month = now.getMonth() + 1
+const day = now.getDate()
+const hour = now.getHours()
+const minute = now.getMinutes()
+
+// 年(当前年份+未来2年)
+const years = []
+for (let i = year; i <= year + 2; i++) {
+years.push(i + '年')
+}
+
+// 月
+const months = []
+for (let i = 1; i <= 12; i++) {
+months.push(i + '月')
+}
+
+// 日
+const days = []
+const daysInMonth = new Date(year, month, 0).getDate()
+for (let i = 1; i <= daysInMonth; i++) {
+days.push(i + '日')
+}
+
+// 时
+const hours = []
+for (let i = 0; i < 24; i++) {
+hours.push(i.toString().padStart(2, '0') + '时')
+}
+
+// 分
+const minutes = []
+for (let i = 0; i < 60; i += 5) {
+minutes.push(i.toString().padStart(2, '0') + '分')
+}
+
+this.dateTimeRange = [years, months, days, hours, minutes]
+},
+onChildChange(e) {
+this.selectedChild = this.children[e.detail.value]
+this.form.childId = this.selectedChild.id
+},
+onCategoryChange(e) {
+this.form.category = this.categories[e.detail.value]
+},
+onDateTimeChange(e) {
+const vals = e.detail.value
+this.dateTimeIndex = vals
+
+const now = new Date()
+const year = now.getFullYear() + vals[0]
+const month = vals[1] + 1
+const day = vals[2] + 1
+const hour = vals[3]
+const minute = vals[4] * 5
+
+this.deadlineText = `${year}年${month}月${day}日 ${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
+this.form.deadline = new Date(year, month - 1, day, hour, minute)
+},
+onColumnChange(e) {
+// 处理月份变化时更新天数
+if (e.detail.column === 1) {
+const month = e.detail.value + 1
+const now = new Date()
+const year = now.getFullYear() + this.dateTimeIndex[0]
+const daysInMonth = new Date(year, month, 0).getDate()
+const days = []
+for (let i = 1; i <= daysInMonth; i++) {
+days.push(i + '日')
+}
+this.$set(this.dateTimeRange, 2, days)
+}
+},
+onRepeatChange(e) {
+const selected = this.repeatTypes[e.detail.value]
+this.form.repeatType = selected.value
+},
+onNeedReviewChange(e) {
+this.form.needReview = e.detail.value
+},
+async submit() {
+if (!this.form.title) {
+uni.showToast({ title: '请输入任务名称', icon: 'none' })
+return
+}
+if (!this.form.childId) {
+uni.showToast({ title: '请选择孩子', icon: 'none' })
+return
+}
+if (!this.form.deadline) {
+uni.showToast({ title: '请选择截止时间', icon: 'none' })
+return
+}
+
+try {
+await createTask({
+title: this.form.title,
+description: this.form.description,
+childId: this.form.childId,
+points: parseInt(this.form.points) || 2,
+category: this.form.category || '其他',
+deadline: this.form.deadline.toISOString(),
+repeatType: this.form.repeatType,
+needReview: this.form.needReview ? 1 : 0
+})
+uni.showToast({ title: '创建成功', icon: 'success' })
+setTimeout(() => uni.navigateBack(), 1500)
+} catch (e) {
+uni.showToast({ title: '创建失败', icon: 'none' })
+}
+}
+}
 }
 </script>
 
@@ -116,7 +230,7 @@ export default {
 .form-container { background: #fff; border-radius: 20rpx; padding: 30rpx; }
 .form-item { margin-bottom: 30rpx; }
 .label { display: block; font-size: 28rpx; color: #333; margin-bottom: 10rpx; }
-.input, .textarea { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; }
+.input, .textarea { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; width: 100%; box-sizing: border-box; }
 .textarea { height: 150rpx; }
 .picker { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; color: #666; }
 .btn-primary { background: #FF6B6B; color: #fff; border-radius: 20rpx; margin-top: 40rpx; }

+ 2 - 0
zxyj-frontend/store/index.js

@@ -32,9 +32,11 @@ const store = new Vuex.Store({
     },
     setCurrentChild(state, childId) {
       state.currentChildId = childId
+      uni.setStorageSync('currentChildId', childId)
     },
     switchRole(state, role) {
       state.role = role
+      uni.setStorageSync('role', role)
     },
     logout(state) {
       state.token = ''

+ 18 - 0
zxyj-frontend/utils/api.js

@@ -50,6 +50,24 @@ export const phoneLogin = (data) => {
   return request('/api/auth/phone-login', 'POST', data)
 }
 
+// ===================== 任务相关 =====================
+
+export const getTodayTasks = (childId) => {
+  return request('/api/tasks/today', 'GET', { childId })
+}
+
+export const completeTask = (taskId, childId, photoUrl) => {
+  return request(`/api/tasks/${taskId}/complete?childId=${childId}`, 'POST', { photoUrl })
+}
+
+export const createTask = (data) => {
+  return request('/api/tasks', 'POST', data)
+}
+
+export const getChildren = () => {
+  return request('/api/user/children', 'GET')
+}
+
 // ===================== 街道地址相关 =====================
 
 // 获取所有省份

+ 1 - 1
zxyj-web/src/api/operationLog.js

@@ -2,7 +2,7 @@ import request from '@/utils/request'
 
 export function getOperationLogs(params) {
   return request({
-    url: '/api/admin/logs/operations',
+    url: '/api/admin/',
     method: 'get',
     params
   })

+ 10 - 3
zxyj-web/src/router/index.js

@@ -111,14 +111,21 @@ const router = new VueRouter({
 router.beforeEach((to, from, next) => {
   const token = localStorage.getItem('token')
   const role = localStorage.getItem('role')
+  
   // authentication check
   if (to.path !== '/login' && !token) {
     return next('/login')
   }
-  // teacher role protection for /guide* routes
-  if ((to.path.startsWith('/guide') || to.path === '/teacher-consult') && role !== 'teacher') {
-    return next({ path: '/', replace: true })
+  
+  // teacher role protection for guide routes (guide-packages, guide-family-task, teacher-consult)
+  const guideRoutes = ['GuidePackages', 'GuideFamilyTask', 'TeacherConsult']
+  if (guideRoutes.includes(to.name)) {
+    if (!role || role !== 'teacher') {
+      // 非指导师角色重定向到首页,避免重定向循环
+      return next(from.path ? { path: from.path, replace: false } : '/')
+    }
   }
+  
   return next()
 })
 

+ 10 - 9
zxyj-web/src/views/Login.vue

@@ -62,15 +62,16 @@ export default {
         if (valid) {
           this.loading = true
           try {
-            const res = await adminLogin({
-              phone: this.loginForm.username,
-              password: this.loginForm.password
-            })
-            localStorage.setItem('token', res.data.token)
-            localStorage.setItem('adminId', res.data.adminId)
-            localStorage.setItem('adminName', res.data.realName)
-            this.$message.success('登录成功')
-            this.$router.push('/')
+      const res = await adminLogin({
+        phone: this.loginForm.username,
+        password: this.loginForm.password
+      })
+      localStorage.setItem('token', res.data.token)
+      localStorage.setItem('adminId', res.data.adminId)
+      localStorage.setItem('adminName', res.data.realName)
+      localStorage.setItem('role', res.data.role || 'admin')
+      this.$message.success('登录成功')
+      this.$router.push('/')
           } catch (e) {
             this.$message.error(e.message || '登录失败')
           } finally {