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

feat(OPLOG): 实现用户操作日志功能

- 添加UserOperationLog实体、Mapper、Service
- OperationLogController改用POST方法(安全要求)
- Web端API调用适配POST方法
- 改进Layout菜单和角色显示逻辑
- 路由守卫改进指导师权限检查
User 6 месяцев назад
Родитель
Сommit
a4d12efc90

+ 82 - 1
docs/requirements-analysis.md

@@ -282,4 +282,85 @@
 
 - 跳转到 `/pages/user-edit/user-edit` 页面
 - 用户可修改昵称、头像、性别、生日等信息
-- 指导师可修改指导师证书号、证书照片等信息
+- 指导师可修改指导师证书号、证书照片等信息
+
+---
+
+## 八、任务创建功能
+
+### 8.1 必填项
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| 标题 | String | 任务名称 |
+| 内容 | String | 任务描述 |
+| 类型 | String | 学习类/生活类/运动类/其他 |
+| 完成方式 | List | 打卡/文本/语音/图片/视频,可多选 |
+| 积分 | Integer | 1-10分 |
+| 时长 | Integer | 分钟 |
+| 完成孩子 | List | 多个孩子可多选 |
+| 频率 | String | daily/weekly/monthly |
+| 周期次数 | Integer | 周期内最多完成次数,超过不计积分 |
+| 任务性质 | String | 一次性onetime/循环recurring |
+| 是否需要审核 | Integer | 0否/1是 |
+| 审核方式 | String | creator/parent/teacher/ai |
+
+### 8.2 非必填项
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| 最早开始时间 | DateTime | 不设置表示不限时 |
+| 最晚结束时间 | DateTime | 不设置表示不限时 |
+
+### 8.3 任务创建入口
+
+**小程序端**:
+- 家长首页 → "+ 添加任务"按钮
+- 任务列表页面 → "+ 添加任务"按钮
+
+**Web端**:
+- 任务管理 → 创建任务
+
+### 8.4 任务模板
+
+**模板来源**:
+1. 历史任务 - 用户曾经创建的任务
+2. 公开模板 - 系统预设的任务模板
+
+**模板内容**:
+- 标题、内容、类型
+- 完成方式、积分、时长
+- 默认审核设置
+
+### 8.5 任务表结构
+
+```sql
+tasks 表新增字段:
+- executor_type VARCHAR(20) COMMENT '执行者类型: child/parent'
+- executor_id BIGINT COMMENT '执行者ID'
+- task_type VARCHAR(20) COMMENT '任务性质: onetime/recurring'
+- complete_types JSON COMMENT '完成方式: ["checkin","text","image"...]'
+- review_type VARCHAR(20) COMMENT '审核人类型: creator/parent/teacher/ai'
+- earliest_start DATETIME COMMENT '最早开始时间'
+- latest_end DATETIME COMMENT '最晚结束时间'
+- max_frequency INT COMMENT '周期内最多完成次数'
+```
+
+### 8.6 审核流程
+
+| 审核人类型 | 说明 |
+|------------|------|
+| creator | 任务创建人审核 |
+| parent | 家长审核 |
+| teacher | 指导师审核 |
+| ai | AI自动审核 |
+
+### 8.7 实现状态
+
+| 功能 | 状态 | 说明 |
+|------|------|------|
+| 后端DTO | ✅ 完成 | CreateTaskDTO 已添加所有字段 |
+| 后端实体 | ✅ 完成 | Task 实体已添加所有字段 |
+| 后端Service | ✅ 完成 | TaskService.createTask 已更新 |
+| 小程序端 | ✅ 完成 | tasks.vue 已实现完整表单 |
+| Web端 | ⏳ 待开发 | 需要单独实现 |

+ 29 - 8
zxyj-backend/src/main/java/com/zxyj/dto/CreateTaskDTO.java

@@ -1,19 +1,35 @@
 package com.zxyj.dto;
 
 import lombok.Data;
-
 import java.util.Date;
+import java.util.List;
 
 @Data
 public class CreateTaskDTO {
+    // 必填项
     private String title;
     private String description;
-    private Integer points;
-    private Date deadline;
-    private String repeatType;
-    private String category;
-    private Integer needReview;
-    private Integer reviewByCategory;
+    private String category;  // 类型:学习类/生活类/运动类/其他
+    private Integer points;   // 积分
+    private Integer duration; // 时长(分钟)
+    private List<Long> childIds; // 完成的孩子(多选)
+    
+    // 任务性质
+    private String taskType;  // 一次性/循环 recurring
+    private String frequency; // 频率:daily/weekly/monthly
+    private Integer maxFrequency; // 周期内最多完成次数
+    
+    // 审核
+    private Integer needReview; // 是否需要审核
+    private String reviewType;  // 审核人类型:creator/parent/teacher/ai
+    private Integer reviewByCategory; // 按分类审核
+    
+    // 完成方式(多选)
+    private List<String> completeTypes; // 打卡checkin/文本text/语音audio/图片image/视频video
+    
+    // 非必填
+    private Date earliestStart; // 最早开始时间
+    private Date latestEnd;    // 最晚结束时间
     
     // 执行者类型: child(孩子) 或 parent(家长)
     private String executorType;
@@ -21,4 +37,9 @@ public class CreateTaskDTO {
     private Long executorId;
     // 兼容旧版 - 指定孩子ID
     private Long childId;
-}
+    
+    // 重复类型(兼容旧版)
+    private String repeatType;
+    // 截止时间(兼容旧版)
+    private Date deadline;
+}

+ 37 - 0
zxyj-backend/src/main/resources/create-teacher-account.sql

@@ -0,0 +1,37 @@
+-- 创建测试指导师账号
+-- 用于测试指导师管理功能(指导师套餐、家庭任务管理、家长咨询)
+
+-- 1. 创建一个指导师角色的用户
+INSERT INTO users (phone, nickname, role, status, created_at, updated_at)
+VALUES ('13800138000', '测试指导师', 'teacher', 'active', NOW(), NOW())
+ON DUPLICATE KEY UPDATE role='teacher';
+
+-- 2. 获取刚插入的用户 ID(假设是最后一个)
+SET @teacher_user_id = (SELECT id FROM users WHERE phone = '13800138000' LIMIT 1);
+
+-- 3. 在 teachers 表中创建指导师记录
+INSERT INTO teachers (user_id, teacher_no, title, specialty, status, created_at, updated_at)
+SELECT 
+  @teacher_user_id,
+  'T2026001',
+  '资深家庭教育指导师',
+  '亲子关系、习惯培养',
+  'active',
+  NOW(),
+  NOW()
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM teachers WHERE user_id = @teacher_user_id
+);
+
+-- 4. 验证创建结果
+SELECT 
+  u.id,
+  u.phone,
+  u.nickname,
+  u.role,
+  t.teacher_no,
+  t.title
+FROM users u
+LEFT JOIN teachers t ON u.id = t.user_id
+WHERE u.phone = '13800138000';

+ 291 - 30
zxyj-frontend/pages/tasks/tasks.vue

@@ -44,31 +44,121 @@
 
     <!-- 添加任务弹窗 -->
     <view class="modal-mask" v-if="showAddModal" @click="showAddModal = false">
-      <view class="modal" @click.stop>
-        <view class="modal-title">添加任务</view>
+      <view class="modal modal-large" @click.stop>
+        <view class="modal-title">创建任务</view>
+        
+        <!-- 任务模板选择 -->
         <view class="form-item">
-          <input v-model="newTask.title" placeholder="任务名称" />
+          <view class="form-label">从模板选择(可选)</view>
+          <picker mode="selector" :range="taskTemplates" range-key="title" @change="onTemplateChange">
+            <view class="picker">{{ selectedTemplate ? selectedTemplate.title : '选择任务模板' }}</view>
+          </picker>
         </view>
+        
+        <!-- 必填:标题 -->
         <view class="form-item">
-          <input v-model="newTask.points" type="number" placeholder="积分(1-10)" />
+          <view class="form-label">任务标题 <text class="required">*</text></view>
+          <input v-model="newTask.title" placeholder="请输入任务标题" />
         </view>
+        
+        <!-- 内容 -->
         <view class="form-item">
-          <picker mode="time" @change="onTimeChange">
-            <view class="picker">
-              {{ newTask.deadlineTime || '选择截止时间' }}
-            </view>
-          </picker>
+          <view class="form-label">任务内容</view>
+          <textarea v-model="newTask.description" placeholder="请输入任务描述" class="textarea" />
         </view>
+        
+        <!-- 类型 -->
         <view class="form-item">
+          <view class="form-label">类型 <text class="required">*</text></view>
           <picker mode="selector" :range="categories" @change="onCategoryChange">
-            <view class="picker">
-              {{ newTask.category || '选择分类' }}
-            </view>
+            <view class="picker">{{ newTask.category || '请选择类型' }}</view>
+          </picker>
+        </view>
+        
+        <!-- 完成方式 -->
+        <view class="form-item">
+          <view class="form-label">完成方式 <text class="required">*</text></view>
+          <checkbox-group @change="onCompleteTypeChange">
+            <label v-for="type in completeTypes" :key="type.value">
+              <checkbox :value="type.value" :checked="newTask.completeTypes.includes(type.value)" />
+              <text>{{ type.label }}</text>
+            </label>
+          </checkbox-group>
+        </view>
+        
+        <!-- 积分和时长 -->
+        <view class="form-row">
+          <view class="form-item half">
+            <view class="form-label">积分 <text class="required">*</text></view>
+            <input v-model="newTask.points" type="number" placeholder="1-10" />
+          </view>
+          <view class="form-item half">
+            <view class="form-label">时长(分钟)</view>
+            <input v-model="newTask.duration" type="number" placeholder="时长" />
+          </view>
+        </view>
+        
+        <!-- 完成孩子(多选) -->
+        <view class="form-item">
+          <view class="form-label">完成孩子 <text class="required">*</text></view>
+          <checkbox-group @change="onChildSelectChange">
+            <label v-for="child in children" :key="child.id">
+              <checkbox :value="child.id" :checked="newTask.childIds.includes(child.id)" />
+              <text>{{ child.nickname }}</text>
+            </label>
+          </checkbox-group>
+        </view>
+        
+        <!-- 任务性质 -->
+        <view class="form-item">
+          <view class="form-label">任务性质 <text class="required">*</text></view>
+          <radio-group @change="onTaskTypeChange">
+            <label><radio value="onetime" :checked="newTask.taskType === 'onetime'" />一次性</label>
+            <label><radio value="recurring" :checked="newTask.taskType === 'recurring'" />循环任务</label>
+          </radio-group>
+        </view>
+        
+        <!-- 循环任务:频率和次数 -->
+        <view class="form-item" v-if="newTask.taskType === 'recurring'">
+          <view class="form-label">频率</view>
+          <picker mode="selector" :range="frequencies" @change="onFrequencyChange">
+            <view class="picker">{{ newTask.frequency || '请选择频率' }}</view>
+          </picker>
+        </view>
+        <view class="form-item" v-if="newTask.taskType === 'recurring'">
+          <view class="form-label">周期内最多完成次数</view>
+          <input v-model="newTask.maxFrequency" type="number" placeholder="超过次数不记积分" />
+        </view>
+        
+        <!-- 审核设置 -->
+        <view class="form-item">
+          <view class="form-label">需要审核</view>
+          <switch :checked="newTask.needReview === 1" @change="onNeedReviewChange" />
+        </view>
+        <view class="form-item" v-if="newTask.needReview === 1">
+          <view class="form-label">审核人</view>
+          <picker mode="selector" :range="reviewTypes" range-key="label" @change="onReviewTypeChange">
+            <view class="picker">{{ getReviewTypeLabel(newTask.reviewType) }}</view>
           </picker>
         </view>
+        
+        <!-- 非必填:时间 -->
+        <view class="form-item">
+          <view class="form-label">最早开始时间</view>
+          <picker mode="datetime" :value="newTask.earliestStart" @change="onEarliestStartChange">
+            <view class="picker">{{ newTask.earliestStart || '选择时间(不填表示不限时)' }}</view>
+          </picker>
+        </view>
+        <view class="form-item">
+          <view class="form-label">最晚结束时间</view>
+          <picker mode="datetime" :value="newTask.latestEnd" @change="onLatestEndChange">
+            <view class="picker">{{ newTask.latestEnd || '选择时间(不填表示不限时)' }}</view>
+          </picker>
+        </view>
+        
         <view class="modal-btns">
           <button @click="showAddModal = false">取消</button>
-          <button class="btn-primary" @click="createTask">确定</button>
+          <button class="btn-primary" @click="createTask">创建任务</button>
         </view>
       </view>
     </view>
@@ -87,21 +177,61 @@ export default {
       tasks: [],
       isParent: false,
       currentChildId: '',
+      children: [],  // 孩子列表
+      taskTemplates: [],  // 任务模板
+      selectedTemplate: null,
       showAddModal: false,
       newTask: {
         title: '',
+        description: '',
+        category: '',
         points: 2,
-        deadlineTime: '',
-        category: ''
+        duration: '',
+        childIds: [],
+        taskType: 'onetime',
+        frequency: '',
+        maxFrequency: '',
+        completeTypes: ['checkin'],
+        needReview: 0,
+        reviewType: 'parent',
+        earliestStart: '',
+        latestEnd: ''
       },
-      categories: ['学习类', '生活类', '运动类', '其他']
+      categories: ['学习类', '生活类', '运动类', '其他'],
+      completeTypes: [
+        { value: 'checkin', label: '打卡' },
+        { value: 'text', label: '文本' },
+        { value: 'audio', label: '语音' },
+        { value: 'image', label: '图片' },
+        { value: 'video', label: '视频' }
+      ],
+      frequencies: ['每天', '每周', '每月'],
+      reviewTypes: [
+        { value: 'creator', label: '创建人' },
+        { value: 'parent', label: '家长' },
+        { value: 'teacher', label: '指导师' },
+        { value: 'ai', label: 'AI自动' }
+      ]
     }
   },
   onShow() {
     this.loadTasks()
+    this.loadChildren()
     this.isParent = this.$store.state.role === 'parent'
   },
   methods: {
+    async loadChildren() {
+      try {
+        const res = await getChildren()
+        this.children = res.data || []
+        // 默认选中第一个孩子
+        if (this.children.length > 0) {
+          this.newTask.childIds = [this.children[0].id]
+        }
+      } catch (e) {
+        console.error('加载孩子失败', e)
+      }
+    },
     async loadTasks() {
       try {
         const childrenRes = await getChildren()
@@ -127,39 +257,126 @@ export default {
         console.error('完成任务失败', e)
       }
     },
-    onTimeChange(e) {
-      const now = new Date()
-      const deadline = new Date(now.getFullYear(), now.getMonth(), now.getDate(), e.detail.value)
-      this.newTask.deadlineTime = e.detail.value
+    // 模板选择
+    onTemplateChange(e) {
+      if (e.detail.value >= 0) {
+        this.selectedTemplate = this.taskTemplates[e.detail.value]
+        this.applyTemplate(this.selectedTemplate)
+      }
     },
+    applyTemplate(template) {
+      this.newTask.title = template.title || ''
+      this.newTask.description = template.description || ''
+      this.newTask.category = template.category || ''
+      this.newTask.points = template.points || 2
+      this.newTask.duration = template.duration || ''
+      this.newTask.completeTypes = template.completeTypes ? template.completeTypes.split(',') : ['checkin']
+      this.newTask.needReview = template.needReview || 0
+      this.newTask.reviewType = template.reviewType || 'parent'
+    },
+    // 类型选择
     onCategoryChange(e) {
       this.newTask.category = this.categories[e.detail.value]
     },
+    // 完成方式选择
+    onCompleteTypeChange(e) {
+      this.newTask.completeTypes = e.detail.value
+    },
+    // 孩子多选
+    onChildSelectChange(e) {
+      this.newTask.childIds = e.detail.value.map(id => parseInt(id))
+    },
+    // 任务性质
+    onTaskTypeChange(e) {
+      this.newTask.taskType = e.detail.value
+    },
+    // 频率选择
+    onFrequencyChange(e) {
+      const freq = this.frequencies[e.detail.value]
+      this.newTask.frequency = freq === '每天' ? 'daily' : (freq === '每周' ? 'weekly' : 'monthly')
+    },
+    // 是否需要审核
+    onNeedReviewChange(e) {
+      this.newTask.needReview = e.detail.value ? 1 : 0
+    },
+    // 审核人选择
+    onReviewTypeChange(e) {
+      this.newTask.reviewType = this.reviewTypes[e.detail.value].value
+    },
+    getReviewTypeLabel(value) {
+      const found = this.reviewTypes.find(r => r.value === value)
+      return found ? found.label : '请选择审核人'
+    },
+    // 时间选择
+    onEarliestStartChange(e) {
+      this.newTask.earliestStart = e.detail.value
+    },
+    onLatestEndChange(e) {
+      this.newTask.latestEnd = e.detail.value
+    },
     async createTask() {
+      // 验证必填
       if (!this.newTask.title) {
-        uni.showToast({ title: '请输入任务名称', icon: 'none' })
+        uni.showToast({ title: '请输入任务标题', icon: 'none' })
+        return
+      }
+      if (!this.newTask.category) {
+        uni.showToast({ title: '请选择类型', icon: 'none' })
+        return
+      }
+      if (!this.newTask.completeTypes || this.newTask.completeTypes.length === 0) {
+        uni.showToast({ title: '请选择完成方式', icon: 'none' })
+        return
+      }
+      if (!this.newTask.childIds || this.newTask.childIds.length === 0) {
+        uni.showToast({ title: '请选择完成孩子', icon: 'none' })
         return
       }
-      
-      const now = new Date()
-      const deadline = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
-      const [hours, minutes] = (this.newTask.deadlineTime || '20:00').split(':')
-      deadline.setHours(parseInt(hours), parseInt(minutes), 0, 0)
 
       try {
         await createTask({
           title: this.newTask.title,
-          points: parseInt(this.newTask.points),
-          deadline: deadline.toISOString(),
+          description: this.newTask.description,
           category: this.newTask.category,
-          childId: this.currentChildId
+          points: parseInt(this.newTask.points) || 2,
+          duration: parseInt(this.newTask.duration) || null,
+          childIds: this.newTask.childIds,
+          taskType: this.newTask.taskType,
+          frequency: this.newTask.frequency,
+          maxFrequency: parseInt(this.newTask.maxFrequency) || null,
+          completeTypes: this.newTask.completeTypes,
+          needReview: this.newTask.needReview,
+          reviewType: this.newTask.reviewType,
+          earliestStart: this.newTask.earliestStart || null,
+          latestEnd: this.newTask.latestEnd || null
         })
         uni.showToast({ title: '创建成功', icon: 'success' })
         this.showAddModal = false
+        this.resetNewTask()
         this.loadTasks()
       } catch (e) {
         console.error('创建任务失败', e)
+        uni.showToast({ title: '创建失败', icon: 'none' })
+      }
+    },
+    resetNewTask() {
+      this.newTask = {
+        title: '',
+        description: '',
+        category: '',
+        points: 2,
+        duration: '',
+        childIds: this.children.length > 0 ? [this.children[0].id] : [],
+        taskType: 'onetime',
+        frequency: '',
+        maxFrequency: '',
+        completeTypes: ['checkin'],
+        needReview: 0,
+        reviewType: 'parent',
+        earliestStart: '',
+        latestEnd: ''
       }
+      this.selectedTemplate = null
     },
     formatTime(dateStr) {
       if (!dateStr) return ''
@@ -254,6 +471,12 @@ export default {
   border-radius: 20rpx;
   padding: 40rpx;
   width: 80%;
+  max-height: 80vh;
+  overflow-y: auto;
+}
+
+.modal-large {
+  width: 90%;
 }
 
 .modal-title {
@@ -267,11 +490,49 @@ export default {
   margin-bottom: 20rpx;
 }
 
-.form-item input, .picker {
+.form-label {
+  font-size: 28rpx;
+  color: #333;
+  margin-bottom: 10rpx;
+}
+
+.form-label .required {
+  color: #FF6B6B;
+}
+
+.form-row {
+  display: flex;
+  gap: 20rpx;
+}
+
+.form-item.half {
+  flex: 1;
+}
+
+.form-item input, .picker, .textarea {
   border: 1rpx solid #ddd;
   border-radius: 10rpx;
   padding: 20rpx;
   font-size: 28rpx;
+  width: 100%;
+  box-sizing: border-box;
+}
+
+.textarea {
+  min-height: 100rpx;
+}
+
+.form-item checkbox-group {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20rpx;
+}
+
+.form-item label {
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+  font-size: 28rpx;
 }
 
 .modal-btns {

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

@@ -50,24 +50,6 @@ 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')
-}
-
 // ===================== 街道地址相关 =====================
 
 // 获取所有省份

+ 4 - 2
zxyj-web/src/views/Login.vue

@@ -69,8 +69,10 @@ export default {
       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('登录成功')
+      const role = res.data.role || 'admin'
+      localStorage.setItem('role', role)
+      console.log('Login successful, role:', role)
+      this.$message.success('登录成功,角色:' + role)
       this.$router.push('/')
           } catch (e) {
             this.$message.error(e.message || '登录失败')