Przeglądaj źródła

feat(P0): 实现V1.2 P0需求

- REWARD-001A: 孩子创建心愿单,家长填写积分后进入奖励库
- REWARD-002A: 家庭奖励库,多家长可调整积分
- REWARD-002B: 多孩子心愿单隔离
- POINT-002: 超额积分规则,支持自定义bonusRule
- POINT-009: 积分有效期,支持按任务设置过期天数
- 添加数据库迁移16-19
- 添加积分过期定时任务PointsExpireTask
- 新增操作日志功能
User 6 miesięcy temu
rodzic
commit
5474459b2a
27 zmienionych plików z 1536 dodań i 396 usunięć
  1. 56 0
      zxyj-backend/src/main/java/com/zxyj/config/DatabaseInitializer.java
  2. 154 0
      zxyj-backend/src/main/java/com/zxyj/config/OperationLogInterceptor.java
  3. 7 0
      zxyj-backend/src/main/java/com/zxyj/config/WebConfig.java
  4. 53 0
      zxyj-backend/src/main/java/com/zxyj/controller/admin/OperationLogController.java
  5. 2 1
      zxyj-backend/src/main/java/com/zxyj/dto/LoginResultDTO.java
  6. 10 7
      zxyj-backend/src/main/java/com/zxyj/dto/RewardDTO.java
  7. 14 8
      zxyj-backend/src/main/java/com/zxyj/entity/PointsLog.java
  8. 29 22
      zxyj-backend/src/main/java/com/zxyj/entity/Task.java
  9. 48 0
      zxyj-backend/src/main/java/com/zxyj/entity/UserOperationLog.java
  10. 9 0
      zxyj-backend/src/main/java/com/zxyj/mapper/UserOperationLogMapper.java
  11. 228 42
      zxyj-backend/src/main/java/com/zxyj/service/RewardService.java
  12. 193 91
      zxyj-backend/src/main/java/com/zxyj/service/TaskService.java
  13. 128 0
      zxyj-backend/src/main/java/com/zxyj/service/UserOperationLogService.java
  14. 4 0
      zxyj-backend/src/main/java/com/zxyj/service/UserService.java
  15. 10 3
      zxyj-backend/src/main/java/com/zxyj/service/WechatService.java
  16. 86 0
      zxyj-backend/src/main/java/com/zxyj/task/PointsExpireTask.java
  17. 1 0
      zxyj-backend/src/main/resources/application.yml
  18. 19 13
      zxyj-frontend/pages.json
  19. 50 0
      zxyj-frontend/pages/index/index.vue
  20. 10 6
      zxyj-frontend/pages/index/parent-index.vue
  21. 56 26
      zxyj-frontend/pages/login/login.vue
  22. 59 172
      zxyj-frontend/pages/profile/profile.vue
  23. 74 5
      zxyj-frontend/pages/user-edit/user-edit.vue
  24. 17 0
      zxyj-web/src/api/operationLog.js
  25. 6 0
      zxyj-web/src/router/index.js
  26. 4 0
      zxyj-web/src/views/Layout.vue
  27. 209 0
      zxyj-web/src/views/OperationLogs.vue

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

@@ -649,6 +649,62 @@ log.info("已修改rewards.child_id字段允许NULL");
 // 修改失败,忽略错误
 }
 
+// 迁移15: 创建用户操作日志表
+try {
+jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS user_operation_logs (" +
+"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+"user_id BIGINT COMMENT \'用户ID\', " +
+"nickname VARCHAR(50) COMMENT \'用户昵称\', " +
+"operation_type VARCHAR(20) NOT NULL COMMENT \'操作类型: login/logout/create/update/delete/query等\', " +
+"description VARCHAR(500) COMMENT \'操作描述\', " +
+"request_path VARCHAR(200) COMMENT \'请求路径\', " +
+"request_method VARCHAR(10) COMMENT \'请求方法\', " +
+"request_params TEXT COMMENT \'请求参数(脱敏)\', " +
+"response_status INT COMMENT \'响应状态码\', " +
+"client_ip VARCHAR(50) COMMENT \'客户端IP\', " +
+"user_agent VARCHAR(500) COMMENT \'User-Agent\', " +
+"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+"INDEX idx_user_id (user_id), " +
+"INDEX idx_operation_type (operation_type), " +
+"INDEX idx_created_at (created_at)" +
+") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
+log.info("已创建user_operation_logs表");
+} catch (Exception e) {
+// 表已存在,忽略错误
+}
+
+// 迁移16: tasks表添加bonus_rule字段(POINT-002: 超额积分规则)
+try {
+jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN bonus_rule VARCHAR(500) COMMENT \'超额积分规则JSON\'");
+log.info("已添加bonus_rule列到tasks表");
+} catch (Exception e) {
+// 列已存在,忽略错误
+}
+
+// 迁移17: tasks表添加points_expire_days字段(POINT-009: 积分有效期)
+try {
+jdbcTemplate.execute("ALTER TABLE tasks ADD COLUMN points_expire_days INT DEFAULT 0 COMMENT \'积分有效期天数,0表示永久有效\'");
+log.info("已添加points_expire_days列到tasks表");
+} catch (Exception e) {
+// 列已存在,忽略错误
+}
+
+// 迁移18: points_log表添加expire_at字段(POINT-009: 积分有效期)
+try {
+jdbcTemplate.execute("ALTER TABLE points_log ADD COLUMN expire_at DATE COMMENT \'积分过期日期\'");
+log.info("已添加expire_at列到points_log表");
+} catch (Exception e) {
+// 列已存在,忽略错误
+}
+
+// 迁移19: points_log表添加expired字段(POINT-009: 过期状态标记)
+try {
+jdbcTemplate.execute("ALTER TABLE points_log ADD COLUMN expired TINYINT DEFAULT 0 COMMENT \'是否已过期: 0否1是\'");
+log.info("已添加expired列到points_log表");
+} catch (Exception e) {
+// 列已存在,忽略错误
+}
+
 log.info("数据库迁移完成");
     }
 

+ 154 - 0
zxyj-backend/src/main/java/com/zxyj/config/OperationLogInterceptor.java

@@ -0,0 +1,154 @@
+package com.zxyj.config;
+
+import com.zxyj.service.UserOperationLogService;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+import org.springframework.web.servlet.ModelAndView;
+
+import javax.annotation.Resource;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.BufferedReader;
+
+@Slf4j
+@Component
+public class OperationLogInterceptor implements HandlerInterceptor {
+
+    @Resource
+    private UserOperationLogService logService;
+
+    /**
+     * 不记录日志的路径
+     */
+    private static final String[] EXCLUDE_PATHS = {
+            "/api/auth/send-code",
+            "/api/auth/check-phone",
+            "/api/streets",
+            "/swagger-ui",
+            "/v3/api-docs",
+            "/swagger-resources",
+            "/webjars"
+    };
+
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        // 检查是否需要排除
+        String path = request.getRequestURI();
+        for (String excludePath : EXCLUDE_PATHS) {
+            if (path.contains(excludePath)) {
+                return true;
+            }
+        }
+
+        return true;
+    }
+
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, 
+                        Object handler, Exception ex) throws Exception {
+        try {
+            // 获取用户ID(从拦截器设置)
+            Object userIdObj = request.getAttribute("userId");
+            if (userIdObj == null) {
+                return;
+            }
+            Long userId = (Long) userIdObj;
+
+            // 获取操作类型
+            String method = request.getMethod();
+            String path = request.getRequestURI();
+            String operationType = getOperationType(method, path);
+            
+            // 获取描述
+            String description = method + " " + path;
+
+            // 获取请求参数
+            String requestParams = getRequestParams(request);
+
+            // 获取客户端IP
+            String clientIp = getClientIp(request);
+
+            // 获取User-Agent
+            String userAgent = request.getHeader("User-Agent");
+
+            // 记录日志
+            logService.logOperation(
+                    userId,
+                    operationType,
+                    description,
+                    path,
+                    method,
+                    requestParams,
+                    response.getStatus(),
+                    clientIp,
+                    userAgent
+            );
+        } catch (Exception e) {
+            log.error("记录操作日志失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 根据请求方法和路径获取操作类型
+     */
+    private String getOperationType(String method, String path) {
+        if (path.contains("/login")) {
+            return "login";
+        } else if (path.contains("/logout")) {
+            return "logout";
+        } else if (path.contains("/create") || path.contains("/add")) {
+            return "create";
+        } else if (path.contains("/update") || path.contains("/edit")) {
+            return "update";
+        } else if (path.contains("/delete")) {
+            return "delete";
+        } else if (path.contains("/query") || path.contains("/list") || path.contains("/get/")) {
+            return "query";
+        }
+        
+        // 根据HTTP方法
+        return method.toLowerCase();
+    }
+
+    /**
+     * 获取请求参数
+     */
+    private String getRequestParams(HttpServletRequest request) {
+        try {
+            if ("POST".equalsIgnoreCase(request.getMethod()) || 
+                "PUT".equalsIgnoreCase(request.getMethod())) {
+                StringBuilder sb = new StringBuilder();
+                BufferedReader reader = request.getReader();
+                String line;
+                while ((line = reader.readLine()) != null) {
+                    sb.append(line);
+                }
+                return sb.toString();
+            }
+            // GET参数
+            return request.getQueryString();
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
+    /**
+     * 获取客户端真实IP
+     */
+    private String getClientIp(HttpServletRequest request) {
+        String ip = request.getHeader("X-Forwarded-For");
+        if (StringUtils.isBlank(ip)) {
+            ip = request.getHeader("X-Real-IP");
+        }
+        if (StringUtils.isBlank(ip)) {
+            ip = request.getRemoteAddr();
+        }
+        // 多级代理取第一个IP
+        if (ip != null && ip.contains(",")) {
+            ip = ip.split(",")[0].trim();
+        }
+        return ip;
+    }
+}

+ 7 - 0
zxyj-backend/src/main/java/com/zxyj/config/WebConfig.java

@@ -12,6 +12,9 @@ public class WebConfig implements WebMvcConfigurer {
     @Resource
     private JwtInterceptor jwtInterceptor;
 
+    @Resource
+    private OperationLogInterceptor operationLogInterceptor;
+
     @Override
     public void addCorsMappings(CorsRegistry registry) {
         registry.addMapping("/**")
@@ -38,5 +41,9 @@ public class WebConfig implements WebMvcConfigurer {
                         "/api/admin-auth/login",
                         "/api/admin-auth/login-by-password"
                 );
+
+        // 操作日志拦截器(JWT之后)
+        registry.addInterceptor(operationLogInterceptor)
+                .addPathPatterns("/api/**");
     }
 }

+ 53 - 0
zxyj-backend/src/main/java/com/zxyj/controller/admin/OperationLogController.java

@@ -0,0 +1,53 @@
+package com.zxyj.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zxyj.common.Result;
+import com.zxyj.entity.UserOperationLog;
+import com.zxyj.service.UserOperationLogService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import javax.annotation.Resource;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+
+@Tag(name = "日志管理", description = "操作日志查询接口")
+@RestController
+@RequestMapping("/api/admin/logs")
+public class OperationLogController {
+
+    @Resource
+    private UserOperationLogService logService;
+
+    @Operation(summary = "分页查询操作日志")
+    @GetMapping("/operations")
+    public Result<Page<UserOperationLog>> queryLogs(
+            @RequestAttribute("userId") Long adminId,
+            @RequestParam(required = false) Long userId,
+            @RequestParam(required = false) String operationType,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date startDate,
+            @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") Date endDate,
+            @RequestParam(defaultValue = "1") Integer page,
+            @RequestParam(defaultValue = "20") Integer size) {
+
+        // 可以添加权限检查:admin角色才能查看
+        Page<UserOperationLog> result = logService.queryLogs(
+                userId, operationType, startDate, endDate, page, size);
+
+        return Result.success(result);
+    }
+
+    @Operation(summary = "获取用户最近操作记录")
+    @GetMapping("/recent/{userId}")
+    public Result<List<UserOperationLog>> getRecentLogs(
+            @RequestAttribute("userId") Long adminId,
+            @PathVariable Long userId,
+            @RequestParam(defaultValue = "10") Integer limit) {
+
+        List<UserOperationLog> logs = logService.getRecentLogs(userId, limit);
+        return Result.success(logs);
+    }
+}

+ 2 - 1
zxyj-backend/src/main/java/com/zxyj/dto/LoginResultDTO.java

@@ -11,6 +11,7 @@ public class LoginResultDTO {
     private Long familyId;
     private String nickname;
     private Long currentChildId; // 当前切换到的孩子ID(如果切换了)
-    private Boolean isNewUser; // 是否新用户,用于前端判断是否需要进入信息编辑页面
+    private Boolean isNewUser; // 是否新用户
     private String openid; // 微信openid,用于前端缓存实现自动登录
+    private Boolean needsRoleSelect; // 新用户是否需要选择角色
 }

+ 10 - 7
zxyj-backend/src/main/java/com/zxyj/dto/RewardDTO.java

@@ -4,11 +4,14 @@ import lombok.Data;
 
 @Data
 public class RewardDTO {
-    private Long id;
-    private String title;
-    private Integer pointsRequired;
-    private String category;
-    private String status;
-    private Integer exchangeApproved;
-    private Integer progress;
+private Long id;
+private String title;
+private Integer pointsRequired;
+private String category;
+private String status;
+private Integer exchangeApproved;
+private Integer progress;
+// REWARD-002B: 支持多孩子隔离
+private Long childId;
+private String childName;
 }

+ 14 - 8
zxyj-backend/src/main/java/com/zxyj/entity/PointsLog.java

@@ -12,18 +12,24 @@ import java.util.Date;
 @TableName("points_log")
 public class PointsLog implements Serializable {
 
-    @TableId(type = IdType.AUTO)
-    private Long id;
+@TableId(type = IdType.AUTO)
+private Long id;
 
-    private Long childId;
+private Long childId;
 
-    private Long taskId;
+private Long taskId;
 
-    private Integer amount;
+private Integer amount;
 
-    private String type;
+private String type;
 
-    private String description;
+private String description;
 
-    private Date createdAt;
+private Date createdAt;
+
+// POINT-009: 积分过期日期(NULL表示永久有效)
+private Date expireAt;
+
+// POINT-009: 过期状态标记
+private Integer expired;
 }

+ 29 - 22
zxyj-backend/src/main/java/com/zxyj/entity/Task.java

@@ -12,43 +12,50 @@ import java.util.Date;
 @TableName("tasks")
 public class Task implements Serializable {
 
-    @TableId(type = IdType.AUTO)
-    private Long id;
+@TableId(type = IdType.AUTO)
+private Long id;
 
-    private Long familyId;
+private Long familyId;
 
-    private Long creatorId;
+private Long creatorId;
 
-    // 执行者类型: child(孩子) 或 parent(家长)
-    private String executorType;
+// 执行者类型: child(孩子) 或 parent(家长)
+private String executorType;
 
-    // 执行者ID (当executorType=child时对应child表ID,executorType=parent时对应user表ID)
-    private Long executorId;
+// 执行者ID (当executorType=child时对应child表ID,executorType=parent时对应user表ID)
+private Long executorId;
 
-    // 保留childId字段用于兼容旧数据
-    private Long childId;
+// 保留childId字段用于兼容旧数据
+private Long childId;
 
-    private String title;
+private String title;
 
-    private String description;
+private String description;
 
-    private Integer points;
+private Integer points;
 
-    private Date deadline;
+private Date deadline;
 
-    private String repeatType;
+private String repeatType;
 
-    private String category;
+private String category;
 
-    private Integer needReview;
+private Integer needReview;
 
-    private Integer reviewByCategory;
+private Integer reviewByCategory;
 
-    private String status;
+private String status;
 
-    private Date completedAt;
+private Date completedAt;
 
-    private Date createdAt;
+// POINT-002: 超额积分规则(JSON格式)
+// 格式示例: {"earlyBonus": 1, "earlyMinutes": 30, "qualityBonus": 2, "qualityThreshold": 90}
+private String bonusRule;
 
-    private Date updatedAt;
+// POINT-009: 积分有效期天数(0表示永久有效)
+private Integer pointsExpireDays;
+
+private Date createdAt;
+
+private Date updatedAt;
 }

+ 48 - 0
zxyj-backend/src/main/java/com/zxyj/entity/UserOperationLog.java

@@ -0,0 +1,48 @@
+package com.zxyj.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+import java.util.Date;
+
+@Data
+@TableName("user_operation_logs")
+public class UserOperationLog {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 用户昵称 */
+    private String nickname;
+
+    /** 操作类型: login/logout/create/update/delete/query/export等 */
+    private String operationType;
+
+    /** 操作描述 */
+    private String description;
+
+    /** 请求路径 */
+    private String requestPath;
+
+    /** 请求方法 */
+    private String requestMethod;
+
+    /** 请求参数(脱敏) */
+    private String requestParams;
+
+    /** 响应状态码 */
+    private Integer responseStatus;
+
+    /** 客户端IP */
+    private String clientIp;
+
+    /** User-Agent */
+    private String userAgent;
+
+    /** 操作时间 */
+    private Date createdAt;
+}

+ 9 - 0
zxyj-backend/src/main/java/com/zxyj/mapper/UserOperationLogMapper.java

@@ -0,0 +1,9 @@
+package com.zxyj.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zxyj.entity.UserOperationLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface UserOperationLogMapper extends BaseMapper<UserOperationLog> {
+}

+ 228 - 42
zxyj-backend/src/main/java/com/zxyj/service/RewardService.java

@@ -7,10 +7,13 @@ import com.zxyj.dto.RewardDTO;
 import com.zxyj.entity.Child;
 import com.zxyj.entity.PointsLog;
 import com.zxyj.entity.Reward;
+import com.zxyj.entity.User;
 import com.zxyj.mapper.ChildMapper;
 import com.zxyj.mapper.PointsLogMapper;
 import com.zxyj.mapper.RewardMapper;
+import com.zxyj.mapper.UserMapper;
 import javax.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
@@ -20,42 +23,111 @@ import java.util.List;
 import java.util.Map;
 import java.util.stream.Collectors;
 
+@Slf4j
 @Service
 public class RewardService {
 
-    @Resource
-    private RewardMapper rewardMapper;
+@Resource
+private RewardMapper rewardMapper;
 
-    @Resource
-    private ChildMapper childMapper;
+@Resource
+private ChildMapper childMapper;
 
-    @Resource
-    private PointsLogMapper pointsLogMapper;
+@Resource
+private PointsLogMapper pointsLogMapper;
 
-    @Resource
-    private UserService userService;
+@Resource
+private UserService userService;
 
-    @Transactional
-    public Long createReward(Long userId, CreateRewardDTO dto) {
-        // 获取用户信息并验证家庭归属
-        com.zxyj.entity.User user = userService.getUserInfo(userId);
-        if (user == null || user.getFamilyId() == null) {
-            throw new IllegalArgumentException("用户必须属于某个家庭");
-        }
-        
-        Reward reward = new Reward();
-        reward.setFamilyId(user.getFamilyId()); // 强制关联家庭
-        reward.setChildId(dto.getChildId());
-        reward.setTitle(dto.getTitle());
-        reward.setPointsRequired(dto.getPointsRequired());
-        reward.setCategory(dto.getCategory());
-        reward.setStatus("available");
-        reward.setIsTemplate(0);
-        reward.setCreatedAt(new Date());
-        reward.setUpdatedAt(new Date());
-        rewardMapper.insert(reward);
-        return reward.getId();
-    }
+@Resource
+private UserMapper userMapper;
+
+/**
+* REWARD-001A: 孩子创建心愿单
+* 孩子添加想要的奖励到心愿单,状态为 pending_approval(待家长填写积分)
+*/
+@Transactional
+public Long createWishlist(Long childId, CreateRewardDTO dto) {
+Child child = childMapper.selectById(childId);
+if (child == null) {
+throw new IllegalArgumentException("孩子不存在");
+}
+
+Reward reward = new Reward();
+reward.setFamilyId(child.getFamilyId());
+reward.setChildId(childId);
+reward.setTitle(dto.getTitle());
+reward.setCategory(dto.getCategory());
+// 孩子创建心愿时积分为0,等待家长填写
+reward.setPointsRequired(0);
+reward.setStatus("pending_approval"); // 待家长审批填写积分
+reward.setIsTemplate(0);
+reward.setCreatedAt(new Date());
+reward.setUpdatedAt(new Date());
+rewardMapper.insert(reward);
+
+log.info("孩子{}创建心愿: {}", childId, dto.getTitle());
+return reward.getId();
+}
+
+/**
+* REWARD-001A: 家长填写积分,心愿进入奖励库
+* 家长审批并填写所需积分后,心愿转为可兑换状态
+*/
+@Transactional
+public boolean approveWishlist(Long rewardId, Long parentId, Integer pointsRequired) {
+Reward reward = rewardMapper.selectById(rewardId);
+if (reward == null) {
+return false;
+}
+
+// 验证家长权限:必须是同一家庭的家长
+User parent = userMapper.selectById(parentId);
+if (parent == null || !parent.getFamilyId().equals(reward.getFamilyId())) {
+log.warn("家长{}无权操作奖励{}", parentId, rewardId);
+return false;
+}
+
+// 验证状态:只有待审批的心愿可以填写积分
+if (!"pending_approval".equals(reward.getStatus())) {
+log.warn("奖励{}状态不是待审批: {}", rewardId, reward.getStatus());
+return false;
+}
+
+// 更新积分和状态
+reward.setPointsRequired(pointsRequired);
+reward.setStatus("available"); // 进入奖励库,可兑换
+reward.setUpdatedAt(new Date());
+rewardMapper.updateById(reward);
+
+log.info("家长{}审批心愿{},设置积分: {}", parentId, rewardId, pointsRequired);
+return true;
+}
+
+/**
+* 创建奖励(原有功能:家长直接创建奖励)
+*/
+@Transactional
+public Long createReward(Long userId, CreateRewardDTO dto) {
+// 获取用户信息并验证家庭归属
+User user = userService.getUserInfo(userId);
+if (user == null || user.getFamilyId() == null) {
+throw new IllegalArgumentException("用户必须属于某个家庭");
+}
+
+Reward reward = new Reward();
+reward.setFamilyId(user.getFamilyId()); // 强制关联家庭
+reward.setChildId(dto.getChildId());
+reward.setTitle(dto.getTitle());
+reward.setPointsRequired(dto.getPointsRequired());
+reward.setCategory(dto.getCategory());
+reward.setStatus("available");
+reward.setIsTemplate(0);
+reward.setCreatedAt(new Date());
+reward.setUpdatedAt(new Date());
+rewardMapper.insert(reward);
+return reward.getId();
+}
 
     public List<RewardDTO> getWishlist(Long childId) {
         // 获取孩子信息以验证家庭归属
@@ -162,18 +234,132 @@ public class RewardService {
         return true;
     }
 
-    public List<Reward> getTemplates() {
-        // 模板也必须关联家庭,这里返回所有家庭的模板(family_id > 0)
-        return rewardMapper.selectList(new LambdaQueryWrapper<Reward>()
-                .eq(Reward::getIsTemplate, 1)
-                .gt(Reward::getFamilyId, 0)); // 确保模板关联到具体家庭
-    }
+public List<Reward> getTemplates() {
+// 模板也必须关联家庭,这里返回所有家庭的模板(family_id > 0)
+return rewardMapper.selectList(new LambdaQueryWrapper<Reward>()
+.eq(Reward::getIsTemplate, 1)
+.gt(Reward::getFamilyId, 0)); // 确保模板关联到具体家庭
+}
 
-    public Page<Reward> getExchangeHistory(Long childId, Integer page, Integer size) {
-        Page<Reward> pageParam = new Page<>(page, size);
-        return rewardMapper.selectPage(pageParam, new LambdaQueryWrapper<Reward>()
-                .eq(Reward::getChildId, childId)
-                .in(Reward::getStatus, Arrays.asList("exchanged", "rejected"))
-                .orderByDesc(Reward::getExchangedAt));
-    }
+public Page<Reward> getExchangeHistory(Long childId, Integer page, Integer size) {
+Page<Reward> pageParam = new Page<>(page, size);
+return rewardMapper.selectPage(pageParam, new LambdaQueryWrapper<Reward>()
+.eq(Reward::getChildId, childId)
+.in(Reward::getStatus, Arrays.asList("exchanged", "rejected"))
+.orderByDesc(Reward::getExchangedAt));
+}
+
+/**
+* REWARD-002A: 获取家庭奖励库
+* 返回家庭所有可用的奖励(不含模板),多家长可查看
+*/
+public List<RewardDTO> getFamilyRewardLibrary(Long familyId) {
+List<Reward> rewards = rewardMapper.selectList(new LambdaQueryWrapper<Reward>()
+.eq(Reward::getFamilyId, familyId)
+.eq(Reward::getIsTemplate, 0)
+.in(Reward::getStatus, Arrays.asList("available", "pending_approval"))
+.orderByAsc(Reward::getPointsRequired));
+
+return rewards.stream().map(r -> {
+RewardDTO dto = new RewardDTO();
+dto.setId(r.getId());
+dto.setTitle(r.getTitle());
+dto.setPointsRequired(r.getPointsRequired());
+dto.setCategory(r.getCategory());
+dto.setStatus(r.getStatus());
+dto.setChildId(r.getChildId());
+dto.setExchangeApproved(r.getExchangeApproved());
+return dto;
+}).collect(Collectors.toList());
+}
+
+/**
+* REWARD-002A: 家长修改奖励积分(多家长可调整)
+* 只有同一家庭的家长可以修改
+*/
+@Transactional
+public boolean updateRewardPoints(Long rewardId, Long parentId, Integer newPoints) {
+Reward reward = rewardMapper.selectById(rewardId);
+if (reward == null) {
+return false;
+}
+
+// 验证家长权限
+User parent = userMapper.selectById(parentId);
+if (parent == null || !parent.getFamilyId().equals(reward.getFamilyId())) {
+log.warn("家长{}无权修改奖励{}", parentId, rewardId);
+return false;
+}
+
+// 只有可用状态或待审批状态可以修改
+if (!"available".equals(reward.getStatus()) && !"pending_approval".equals(reward.getStatus())) {
+log.warn("奖励{}状态不可修改: {}", rewardId, reward.getStatus());
+return false;
+}
+
+reward.setPointsRequired(newPoints);
+reward.setUpdatedAt(new Date());
+rewardMapper.updateById(reward);
+
+log.info("家长{}修改奖励{}积分为: {}", parentId, rewardId, newPoints);
+return true;
+}
+
+/**
+* REWARD-002B: 获取孩子的待审批心愿单(按孩子隔离)
+* 只返回指定孩子的心愿,不包含其他孩子
+*/
+public List<RewardDTO> getChildPendingWishlists(Long childId) {
+Child child = childMapper.selectById(childId);
+if (child == null) {
+return null;
+}
+
+List<Reward> rewards = rewardMapper.selectList(new LambdaQueryWrapper<Reward>()
+.eq(Reward::getFamilyId, child.getFamilyId())
+.eq(Reward::getChildId, childId)
+.eq(Reward::getStatus, "pending_approval")
+.orderByDesc(Reward::getCreatedAt));
+
+return rewards.stream().map(r -> {
+RewardDTO dto = new RewardDTO();
+dto.setId(r.getId());
+dto.setTitle(r.getTitle());
+dto.setPointsRequired(r.getPointsRequired());
+dto.setCategory(r.getCategory());
+dto.setStatus(r.getStatus());
+dto.setChildId(r.getChildId());
+return dto;
+}).collect(Collectors.toList());
+}
+
+/**
+* REWARD-002B: 获取家长待处理的心愿审批列表(所有孩子)
+* 家长可以看到所有孩子的待审批心愿
+*/
+public List<RewardDTO> getPendingApprovalList(Long familyId) {
+List<Reward> rewards = rewardMapper.selectList(new LambdaQueryWrapper<Reward>()
+.eq(Reward::getFamilyId, familyId)
+.eq(Reward::getStatus, "pending_approval")
+.eq(Reward::getIsTemplate, 0)
+.orderByDesc(Reward::getCreatedAt));
+
+return rewards.stream().map(r -> {
+RewardDTO dto = new RewardDTO();
+dto.setId(r.getId());
+dto.setTitle(r.getTitle());
+dto.setPointsRequired(r.getPointsRequired());
+dto.setCategory(r.getCategory());
+dto.setStatus(r.getStatus());
+dto.setChildId(r.getChildId());
+
+// 获取孩子昵称
+Child child = childMapper.selectById(r.getChildId());
+if (child != null) {
+dto.setChildName(child.getNickname());
+}
+
+return dto;
+}).collect(Collectors.toList());
+}
 }

+ 193 - 91
zxyj-backend/src/main/java/com/zxyj/service/TaskService.java

@@ -98,70 +98,53 @@ public class TaskService {
         calendar.add(Calendar.DAY_OF_MONTH, 1);
         Date endOfDay = calendar.getTime();
 
-        return taskMapper.selectList(new LambdaQueryWrapper<Task>()
-                .eq(Task::getChildId, childId)
-                .ge(Task::getDeadline, startOfDay)
-                .lt(Task::getDeadline, endOfDay)
-                .in(Task::getStatus, Arrays.asList("pending", "completed"))
-                .orderByAsc(Task::getDeadline));
-    }
+return taskMapper.selectList(new LambdaQueryWrapper<Task>()
+.eq(Task::getChildId, childId)
+.ge(Task::getDeadline, startOfDay)
+.lt(Task::getDeadline, endOfDay)
+.in(Task::getStatus, Arrays.asList("pending", "completed"))
+.orderByAsc(Task::getDeadline));
+}
 
-    @Transactional
-    public Map<String, Object> completeTask(Long taskId, Long childId, String photoUrl) {
-        Task task = taskMapper.selectById(taskId);
-        if (task == null || !task.getChildId().equals(childId)) {
-            return null;
-        }
+@Transactional
+public Map<String, Object> completeTask(Long taskId, Long childId, String photoUrl) {
+Task task = taskMapper.selectById(taskId);
+if (task == null || !task.getChildId().equals(childId)) {
+return null;
+}
 
-        if ("completed".equals(task.getStatus())) {
-            return null; // 任务已完成,不能重复提交
-        }
+if ("completed".equals(task.getStatus())) {
+return null; // 任务已完成,不能重复提交
+}
 
-        Date now = new Date();
-        int pointsEarned = task.getPoints();
-        boolean isEarly = now.before(task.getDeadline());
-        boolean isLateGrace = false;
+Date now = new Date();
+int pointsEarned = task.getPoints();
+boolean isEarly = now.before(task.getDeadline());
+boolean isLateGrace = false;
 
-        // 检查是否在宽限期内
-        if (!isEarly) {
-            long diffMinutes = (now.getTime() - task.getDeadline().getTime()) / (1000 * 60);
-            isLateGrace = diffMinutes <= LATE_GRACE_MINUTES;
-        }
+// 检查是否在宽限期内
+if (!isEarly) {
+long diffMinutes = (now.getTime() - task.getDeadline().getTime()) / (1000 * 60);
+isLateGrace = diffMinutes <= LATE_GRACE_MINUTES;
+}
 
-        Child child = childMapper.selectById(childId);
-        
-        // 计算积分
-        if (isEarly || isLateGrace) {
-            // 提前完成或宽限期内完成,获得基础分 + 提前奖励
-            pointsEarned += EARLY_BONUS;
-        } else {
+Child child = childMapper.selectById(childId);
+
+// POINT-002: 计算积分(支持自定义超额规则)
+String bonusRuleJson = task.getBonusRule();
+if (bonusRuleJson != null && !bonusRuleJson.isEmpty()) {
+// 解析自定义超额积分规则
+pointsEarned = calculatePointsWithBonusRule(task, now, isEarly, isLateGrace, child);
+} else {
+// 使用默认规则
+if (isEarly || isLateGrace) {
+// 提前完成或宽限期内完成,获得基础分 + 提前奖励
+pointsEarned += EARLY_BONUS;
+} else {
             // 超时,检查是否开启扣分
-            if (child.getPenaltyEnabled() != null && child.getPenaltyEnabled() == 1) {
-                // 检查今日已扣分数
-                Calendar cal = Calendar.getInstance();
-                cal.set(Calendar.HOUR_OF_DAY, 0);
-                cal.set(Calendar.MINUTE, 0);
-                cal.set(Calendar.SECOND, 0);
-                Date startOfDay = cal.getTime();
-
-                Integer todayPenalty = pointsLogMapper.selectSum(
-                        new LambdaQueryWrapper<PointsLog>()
-                                .eq(PointsLog::getChildId, childId)
-                                .eq(PointsLog::getType, "penalty")
-                                .ge(PointsLog::getCreatedAt, startOfDay)
-                );
-                
-                int todayPenaltyTotal = todayPenalty != null ? Math.abs(todayPenalty) : 0;
-                if (todayPenaltyTotal < PENALTY_MAX_DAILY) {
-                    int penalty = Math.min(task.getPoints(), PENALTY_MAX_DAILY - todayPenaltyTotal);
-                    pointsEarned = -penalty;
-                } else {
-                    pointsEarned = 0; // 达到每日扣分上限
-                }
-            } else {
-                pointsEarned = 0; // 扣分未开启
-            }
+            pointsEarned = calculateLatePenalty(task, child);
         }
+}
 
         // 更新任务状态
         task.setStatus("completed");
@@ -190,36 +173,44 @@ public class TaskService {
         child.setUpdatedAt(new Date());
         childMapper.updateById(child);
 
-        // 检查并发放打卡里程碑奖励
-        int milestoneReward = streakService.checkAndAwardMilestone(childId, child.getStreakDays());
-        if (milestoneReward > 0) {
-            pointsEarned += milestoneReward;
-            log.info("孩子{}获得打卡里程碑奖励{}积分", childId, milestoneReward);
-        }
+// 检查并发放打卡里程碑奖励
+int milestoneReward = streakService.checkAndAwardMilestone(childId, child.getStreakDays());
+if (milestoneReward > 0) {
+pointsEarned += milestoneReward;
+log.info("孩子{}获得打卡里程碑奖励{}积分", childId, milestoneReward);
+}
 
-        // 记录积分流水
-        PointsLog log = new PointsLog();
-        log.setChildId(childId);
-        log.setTaskId(taskId);
-        log.setAmount(pointsEarned);
-        if (pointsEarned > 0) {
-            log.setType(isEarly ? "bonus" : "earn");
-        } else if (pointsEarned < 0) {
-            log.setType("penalty");
-        } else {
-            log.setType("earn");
-        }
-        log.setDescription("完成任务: " + task.getTitle());
-        log.setCreatedAt(new Date());
-        pointsLogMapper.insert(log);
+// 记录积分流水
+PointsLog pointsLog = new PointsLog();
+pointsLog.setChildId(childId);
+pointsLog.setTaskId(taskId);
+pointsLog.setAmount(pointsEarned);
+if (pointsEarned > 0) {
+pointsLog.setType(isEarly ? "bonus" : "earn");
+} else if (pointsEarned < 0) {
+pointsLog.setType("penalty");
+} else {
+pointsLog.setType("earn");
+}
+pointsLog.setDescription("完成任务: " + task.getTitle());
+pointsLog.setCreatedAt(new Date());
+
+// POINT-009: 设置积分过期时间
+if (pointsEarned > 0 && task.getPointsExpireDays() != null && task.getPointsExpireDays() > 0) {
+Calendar expireCal = Calendar.getInstance();
+expireCal.add(Calendar.DAY_OF_MONTH, task.getPointsExpireDays());
+pointsLog.setExpireAt(expireCal.getTime());
+}
 
-        Map<String, Object> result = new HashMap<>();
-        result.put("pointsEarned", pointsEarned);
-        result.put("newBalance", child.getTotalPoints());
-        result.put("needReview", task.getNeedReview() == 1);
-        
-        return result;
-    }
+pointsLogMapper.insert(pointsLog);
+
+Map<String, Object> result = new HashMap<>();
+result.put("pointsEarned", pointsEarned);
+result.put("newBalance", child.getTotalPoints());
+result.put("needReview", task.getNeedReview() == 1);
+
+return result;
+}
 
     public boolean reviewTask(Long taskId, TaskReviewDTO dto) {
         Task task = taskMapper.selectById(taskId);
@@ -414,10 +405,121 @@ public class TaskService {
         .eq(Task::getChildId, childId)
         .orderByDesc(Task::getCreatedAt);
     
-    if (status != null && !status.isEmpty()) {
-      wrapper.eq(Task::getStatus, status);
-    }
-    
-    return taskMapper.selectPage(pageParam, wrapper);
-  }
+if (status != null && !status.isEmpty()) {
+wrapper.eq(Task::getStatus, status);
+}
+
+return taskMapper.selectPage(pageParam, wrapper);
+}
+
+/**
+* POINT-002: 根据自定义规则计算超额积分
+* bonusRule JSON格式: {
+* "earlyBonus": 1,        // 提前完成额外积分
+* "earlyMinutes": 30,     // 提前多少分钟才算提前(可选)
+* "qualityBonus": 2,      // 超质完成额外积分(家长评分)
+* "qualityThreshold": 90  // 质量评分阈值(可选)
+* }
+*/
+private int calculatePointsWithBonusRule(Task task, Date completedAt, boolean isEarly, boolean isLateGrace, Child child) {
+int points = task.getPoints();
+
+try {
+// 解析JSON规则
+String json = task.getBonusRule();
+if (json == null || json.isEmpty()) {
+return isEarly || isLateGrace ? points + EARLY_BONUS : calculateLatePenalty(task, child, child.getId());
+}
+
+// 简单JSON解析(避免引入额外依赖)
+Integer earlyBonus = extractJsonValue(json, "earlyBonus");
+Integer earlyMinutes = extractJsonValue(json, "earlyMinutes");
+Integer qualityBonus = extractJsonValue(json, "qualityBonus");
+
+// 提前完成奖励
+if (isEarly || isLateGrace) {
+if (earlyBonus != null) {
+points += earlyBonus;
+} else {
+points += EARLY_BONUS; // 默认+1
+}
+
+// 如果设置了提前分钟数,检查是否满足条件
+if (earlyMinutes != null && task.getDeadline() != null) {
+long diffMinutes = (task.getDeadline().getTime() - completedAt.getTime()) / (1000 * 60);
+if (diffMinutes < earlyMinutes) {
+// 不满足提前分钟数,只给基础分
+points = task.getPoints();
+}
+}
+} else {
+// 超时,计算扣分
+points = calculateLatePenalty(task, child, child.getId());
+}
+
+// 超质完成奖励(需要家长后续评分,这里先不处理)
+// qualityBonus 会在家长审核时根据评分添加
+
+} catch (Exception e) {
+log.warn("解析超额积分规则失败: {}", e.getMessage());
+// 解析失败,使用默认规则
+if (isEarly || isLateGrace) {
+points += EARLY_BONUS;
+} else {
+points = calculateLatePenalty(task, child, child.getId());
+}
+}
+
+return points;
+}
+
+/**
+* 从简单JSON字符串中提取整数值
+*/
+private Integer extractJsonValue(String json, String key) {
+try {
+String pattern = "\"" + key + "\"\\s*:\\s*(\\d+)";
+java.util.regex.Pattern p = java.util.regex.Pattern.compile(pattern);
+java.util.regex.Matcher m = p.matcher(json);
+if (m.find()) {
+return Integer.parseInt(m.group(1));
+}
+} catch (Exception e) {
+log.warn("提取JSON值失败: key={}", key);
+}
+return null;
+}
+
+/**
+* 计算超时扣分
+*/
+private int calculateLatePenalty(Task task, Child child, Long childId) {
+int points = task.getPoints();
+
+if (child.getPenaltyEnabled() != null && child.getPenaltyEnabled() == 1) {
+// 检查今日已扣分数
+Calendar cal = Calendar.getInstance();
+cal.set(Calendar.HOUR_OF_DAY, 0);
+cal.set(Calendar.MINUTE, 0);
+cal.set(Calendar.SECOND, 0);
+Date startOfDay = cal.getTime();
+
+Integer todayPenalty = pointsLogMapper.selectSum(
+new LambdaQueryWrapper<PointsLog>()
+.eq(PointsLog::getChildId, childId)
+.eq(PointsLog::getType, "penalty")
+.ge(PointsLog::getCreatedAt, startOfDay)
+);
+
+int todayPenaltyTotal = todayPenalty != null ? Math.abs(todayPenalty) : 0;
+if (todayPenaltyTotal < PENALTY_MAX_DAILY) {
+int penalty = Math.min(task.getPoints(), PENALTY_MAX_DAILY - todayPenaltyTotal);
+return -penalty;
+} else {
+return 0; // 达到每日扣分上限
+}
+} else {
+return 0; // 扣分未开启
+}
+}
 }

+ 128 - 0
zxyj-backend/src/main/java/com/zxyj/service/UserOperationLogService.java

@@ -0,0 +1,128 @@
+package com.zxyj.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zxyj.entity.UserOperationLog;
+import com.zxyj.entity.User;
+import com.zxyj.mapper.UserMapper;
+import com.zxyj.mapper.UserOperationLogMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Slf4j
+@Service
+public class UserOperationLogService {
+
+    @Resource
+    private UserOperationLogMapper logMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    /**
+     * 记录用户操作日志
+     */
+    @Transactional
+    public void logOperation(Long userId, String operationType, String description,
+                        String requestPath, String requestMethod, String requestParams,
+                        Integer responseStatus, String clientIp, String userAgent) {
+        try {
+            UserOperationLog log = new UserOperationLog();
+            log.setUserId(userId);
+            log.setOperationType(operationType);
+            log.setDescription(description);
+            log.setRequestPath(requestPath);
+            log.setRequestMethod(requestMethod);
+            
+            // 脱敏处理请求参数
+            if (StringUtils.isNotBlank(requestParams)) {
+                String sanitizedParams = sanitizeParams(requestParams);
+                log.setRequestParams(sanitizedParams);
+            }
+            
+            log.setResponseStatus(responseStatus);
+            log.setClientIp(clientIp);
+            log.setUserAgent(userAgent);
+            log.setCreatedAt(new Date());
+
+            // 获取用户昵称
+            if (userId != null) {
+                User user = userMapper.selectById(userId);
+                if (user != null) {
+                    log.setNickname(user.getNickname());
+                }
+            }
+
+            logMapper.insert(log);
+        } catch (Exception e) {
+            // 日志记录失败不应影响主业务
+            log.error("记录操作日志失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 脱敏处理请求参数
+     */
+    private String sanitizeParams(String params) {
+        if (params == null) {
+            return null;
+        }
+        // 脱敏敏感字段
+        String[] sensitiveFields = {"password", "token", "code", "idCard", "realName"};
+        String sanitized = params;
+        for (String field : sensitiveFields) {
+            // 替换为脱敏值
+            sanitized = sanitized.replaceAll("\"" + field + "\":\"[^\"]+\"", "\"" + field + "\":\"***\"");
+        }
+        // 限制长度
+        if (sanitized.length() > 500) {
+            sanitized = sanitized.substring(0, 500) + "...";
+        }
+        return sanitized;
+    }
+
+    /**
+     * 查询用户操作日志
+     */
+    public Page<UserOperationLog> queryLogs(Long userId, String operationType, 
+                            Date startDate, Date endDate,
+                            Integer page, Integer size) {
+        Page<UserOperationLog> pageParam = new Page<>(page, size);
+        LambdaQueryWrapper<UserOperationLog> wrapper = new LambdaQueryWrapper<>();
+        
+        if (userId != null) {
+            wrapper.eq(UserOperationLog::getUserId, userId);
+        }
+        if (StringUtils.isNotBlank(operationType)) {
+            wrapper.eq(UserOperationLog::getOperationType, operationType);
+        }
+        if (startDate != null) {
+            wrapper.ge(UserOperationLog::getCreatedAt, startDate);
+        }
+        if (endDate != null) {
+            wrapper.le(UserOperationLog::getCreatedAt, endDate);
+        }
+        
+        wrapper.orderByDesc(UserOperationLog::getCreatedAt);
+        
+        return logMapper.selectPage(pageParam, wrapper);
+    }
+
+    /**
+     * 获取用户最近操作记录
+     */
+    public List<UserOperationLog> getRecentLogs(Long userId, Integer limit) {
+        LambdaQueryWrapper<UserOperationLog> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(UserOperationLog::getUserId, userId);
+        wrapper.orderByDesc(UserOperationLog::getCreatedAt);
+        wrapper.last("LIMIT " + limit);
+        
+        return logMapper.selectList(wrapper);
+    }
+}

+ 4 - 0
zxyj-backend/src/main/java/com/zxyj/service/UserService.java

@@ -147,6 +147,7 @@ private WechatService wechatService;
   result.setFamilyId(user.getFamilyId());
   result.setNickname(user.getNickname());
   result.setIsNewUser(isNewUser);
+  result.setNeedsRoleSelect(isNewUser);
 
   return result;
 }
@@ -226,6 +227,9 @@ private WechatService wechatService;
         result.setIsNewUser(isNewUser);
         result.setOpenid(openid);
 
+        // 新用户需要完善信息(仅当需要选择角色时)
+        result.setNeedsRoleSelect(isNewUser);
+
         return result;
     }
 

+ 10 - 3
zxyj-backend/src/main/java/com/zxyj/service/WechatService.java

@@ -33,6 +33,9 @@ public class WechatService {
     @Value("${wechat.test-mode:false}")
     private boolean testMode;
 
+    @Value("${wechat.test-phone:13800138000}")
+    private String testPhone;
+
     private final RestTemplate restTemplate = new RestTemplate();
 
     /**
@@ -86,12 +89,16 @@ public class WechatService {
         // 测试模式:返回模拟的手机号
         if (testMode) {
             log.info("测试模式:模拟getPhoneNumber返回");
-            // 尝试从code中解析手机号(测试时可以在code中传入真实手机号)
+            // 如果是测试手机号(以 test_ 开头),返回后面的真实手机号
+            if (code != null && code.startsWith("test_")) {
+                return code.substring(5); // test_13800138000 -> 13800138000
+            }
+            // 如果看起来像有效手机号,也直接返回(方便测试)
             if (code != null && code.matches("^1[3-9]\\d{9}$")) {
                 return code;
             }
-            // 默认返回测试手机号
-            return "13800138000";
+            // 默认返回配置的测试手机号
+            return testPhone;
         }
 
         try {

+ 86 - 0
zxyj-backend/src/main/java/com/zxyj/task/PointsExpireTask.java

@@ -0,0 +1,86 @@
+package com.zxyj.task;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.zxyj.entity.PointsLog;
+import com.zxyj.mapper.PointsLogMapper;
+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.Date;
+import java.util.List;
+
+/**
+ * POINT-009: 积分有效期定时任务
+ * 每天凌晨检查并标记过期积分
+ */
+@Slf4j
+@Component
+public class PointsExpireTask {
+
+    @Autowired
+    private PointsLogMapper pointsLogMapper;
+
+    /**
+     * 每天凌晨1点执行
+     * 检查并标记过期的积分记录
+     */
+    @Scheduled(cron = "0 0 1 * * ?")
+    public void checkExpiredPoints() {
+        log.info("开始执行积分过期检查定时任务");
+        
+        try {
+            Date today = new Date();
+            
+            // 查找所有已过期但未标记的积分记录
+            List<PointsLog> expiredLogs = pointsLogMapper.selectList(
+                new LambdaQueryWrapper<PointsLog>()
+                    .eq(PointsLog::getExpired, 0) // 未标记过期
+                    .isNotNull(PointsLog::getExpireAt) // 有过期时间
+                    .lt(PointsLog::getExpireAt, today) // 已过期
+                    .eq(PointsLog::getType, "earn") // 只处理获得类型的积分
+            );
+            
+            if (expiredLogs.isEmpty()) {
+                log.info("没有需要标记过期的积分");
+                return;
+            }
+            
+            // 批量标记为过期
+            for (PointsLog log : expiredLogs) {
+                log.setExpired(1);
+                pointsLogMapper.updateById(log);
+            }
+            
+            log.info("已标记{}条积分记录为过期", expiredLogs.size());
+            
+        } catch (Exception e) {
+            log.error("积分过期检查定时任务执行失败", e);
+        }
+    }
+    
+    /**
+     * 计算有效积分(排除过期积分)
+     * 用于获取孩子当前有效积分类
+     */
+    public int calculateValidPoints(Long childId) {
+        // 获取所有有效积分记录
+        List<PointsLog> validLogs = pointsLogMapper.selectList(
+            new LambdaQueryWrapper<PointsLog>()
+                .eq(PointsLog::getChildId, childId)
+                .and(wrapper -> wrapper
+                    .eq(PointsLog::getExpired, 0) // 未过期
+                    .or()
+                    .isNull(PointsLog::getExpired) // 或未设置过期状态
+                )
+        );
+        
+        int total = 0;
+        for (PointsLog log : validLogs) {
+            total += log.getAmount();
+        }
+        return total;
+    }
+}

+ 1 - 0
zxyj-backend/src/main/resources/application.yml

@@ -40,6 +40,7 @@ wechat:
   login-url: https://api.weixin.qq.com/sns/jscode2session
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   test-mode: true  # 测试模式:跳过微信API调用,使用模拟数据
+  test-phone: "13800138000"  # 测试模式使用的手机号
 
 logging:
   level:

+ 19 - 13
zxyj-frontend/pages.json

@@ -13,18 +13,24 @@
         "navigationBarTitleText": "完善信息"
       }
     },
-    {
-      "path": "pages/index/child-index",
-      "style": {
-        "navigationBarTitleText": "首页"
-      }
-    },
-    {
-      "path": "pages/index/parent-index",
-      "style": {
-        "navigationBarTitleText": "首页"
-      }
-    },
+  {
+    "path": "pages/index/index",
+    "style": {
+      "navigationBarTitleText": "首页"
+    }
+  },
+  {
+    "path": "pages/index/child-index",
+    "style": {
+      "navigationBarTitleText": "首页"
+    }
+  },
+  {
+    "path": "pages/index/parent-index",
+    "style": {
+      "navigationBarTitleText": "首页"
+    }
+  },
     {
       "path": "pages/tasks/tasks",
       "style": {
@@ -170,7 +176,7 @@
     "navigationBarBackgroundColor": "#FFFFFF",
     "backgroundColor": "#F8F8F8"
   },
-  "tabBar": {
+"tabBar": {
     "color": "#7A7E83",
     "selectedColor": "#FF6B6B",
     "borderStyle": "black",

+ 50 - 0
zxyj-frontend/pages/index/index.vue

@@ -0,0 +1,50 @@
+<template>
+  <view>
+    <!-- 家长端首页 -->
+    <parent-index v-if="currentRole === 'parent'" />
+    
+    <!-- 孩子端首页 -->
+    <child-index v-else-if="currentRole === 'child'" />
+    
+    <!-- 指导师首页 -->
+    <teacher-index v-else-if="currentRole === 'teacher'" />
+    
+    <!-- 默认显示加载中或错误提示 -->
+    <view v-else class="loading-container">
+      <text>加载中...</text>
+    </view>
+  </view>
+</template>
+
+<script>
+import ParentIndex from './parent-index.vue'
+import ChildIndex from './child-index.vue'
+import TeacherIndex from '../teacher/index.vue'
+
+export default {
+  components: {
+    ParentIndex,
+    ChildIndex,
+    TeacherIndex
+  },
+  data() {
+    return {
+      currentRole: ''
+    }
+  },
+  onLoad() {
+    // 从本地存储或 store 获取角色
+    this.currentRole = uni.getStorageSync('role') || 'parent'
+  },
+  methods: {}
+}
+</script>
+
+<style>
+.loading-container {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100vh;
+}
+</style>

+ 10 - 6
zxyj-frontend/pages/index/parent-index.vue

@@ -132,7 +132,11 @@ export default {
         // 获取家庭所有成员(家长+孩子)
         try {
           const membersRes = await getFamilyMembers()
-          this.familyMembers = membersRes.data || []
+          const data = membersRes.data || {}
+          // 合并家长和孩子为成员列表
+          const parents = (data.parents || []).map(p => ({ ...p, role: 'parent' }))
+          const children = (data.children || []).map(c => ({ ...c, role: 'child' }))
+          this.familyMembers = [...parents, ...children]
         } catch(e) {
           console.log('获取家庭成员失败,使用孩子列表', e)
           this.familyMembers = []
@@ -151,11 +155,11 @@ export default {
     },
     selectMember(member) {
       if (member.role === 'child') {
-        this.$store.commit('setCurrentChild', member.id)
-        uni.showToast({
-          title: `已选择 ${member.nickname}`,
-          icon: 'none'
-        })
+        // 直接切换到孩子模式
+        this.switchToChildMode(member)
+      } else {
+        // 家长点击显示提示
+        uni.showToast({ title: '点击右上角切换模式', icon: 'none' })
       }
     },
     handleMemberLongPress(member) {

+ 56 - 26
zxyj-frontend/pages/login/login.vue

@@ -37,21 +37,34 @@
             {{ countdown > 0 ? countdown + 's' : '发送验证码' }}
           </button>
         </view>
-      </view>
+        </view>
 
         <button class="login-btn" :loading="loading" @click="phoneLogin">登录</button>
-    </view>
 
-    <view class="agreement">
-      <checkbox-group @change="onAgreementChange">
-        <label>
-          <checkbox value="agree" :checked="agreed" />
-          <text class="agreement-text">我已阅读并同意</text>
-        </label>
-      </checkbox-group>
-      <text class="link" @click="showAgreement">《用户协议》</text>
-      <text class="agreement-text">和</text>
-      <text class="link" @click="showPrivacy">《隐私政策》</text>
+        <!-- 微信一键登录 -->
+        <view class="wechat-section">
+          <button 
+            class="wechat-btn" 
+            open-type="getPhoneNumber" 
+            @getphonenumber="handleWechatPhoneLogin"
+          >
+            <text class="wechat-icon">微信</text>
+            <text>一键登录</text>
+          </button>
+        </view>
+      </view>
+
+      <view class="agreement">
+        <checkbox-group @change="onAgreementChange">
+          <label>
+            <checkbox value="agree" :checked="agreed" />
+            <text class="agreement-text">我已阅读并同意</text>
+          </label>
+        </checkbox-group>
+        <text class="link" @click="showAgreement">《用户协议》</text>
+        <text class="agreement-text">和</text>
+        <text class="link" @click="showPrivacy">《隐私政策》</text>
+      </view>
     </view>
   </view>
 </template>
@@ -204,11 +217,19 @@ export default {
           userId: res.data.userId
         })
 
-uni.showToast({ title: '登录成功', icon: 'success' })
+        uni.showToast({ title: '登录成功', icon: 'success' })
 
-        // 根据角色跳转到对应首页
+        // 根据角色跳转到对应首页,或进入信息编辑页面
         setTimeout(() => {
-          this.navigateToHome(res.data.role)
+          if (res.data.needsRoleSelect) {
+            // 新用户需要选择角色
+            uni.redirectTo({
+              url: `/pages/user-edit/user-edit?token=${res.data.token}&userId=${res.data.userId}&needsRoleSelect=true`
+            })
+          } else {
+            // 老用户直接进入首页
+            this.navigateToHome(res.data.role)
+          }
         }, 1000)
       } catch (e) {
         uni.showToast({ title: e.message || '登录失败', icon: 'none' })
@@ -216,22 +237,22 @@ uni.showToast({ title: '登录成功', icon: 'success' })
         this.loading = false
       }
     },
-    async handleWechatPhoneLogin(e) {
+async handleWechatPhoneLogin(e) {
       if (e.detail.errMsg === 'getPhoneNumber:ok') {
         if (!this.agreed) {
           uni.showToast({ title: '请先同意用户协议', icon: 'none' })
           return
         }
 
+        // 测试环境:使用用户输入的手机号
+        const inputPhone = this.phone
+        if (!inputPhone || !/^1[3-9]\d{9}$/.test(inputPhone)) {
+          uni.showToast({ title: '请先输入手机号', icon: 'none' })
+          return
+        }
+
         this.loading = true
         try {
-          // 新版微信小程序获取手机号:使用 e.detail.code
-          const phoneCode = e.detail.code
-          if (!phoneCode) {
-            uni.showToast({ title: '获取手机号失败,请重试', icon: 'none' })
-            return
-          }
-
           // 获取登录 code 用于获取 openid
           const loginRes = await new Promise((resolve, reject) => {
             uni.login({
@@ -242,9 +263,10 @@ uni.showToast({ title: '登录成功', icon: 'success' })
           })
 
           // 调用后端微信手机号登录接口
+          // 测试环境:将用户输入的手机号作为 phoneCode 传递
           const res = await wechatPhoneLogin({
             code: loginRes.code, // 用于获取 openid
-            phoneCode: phoneCode, // 用于获取手机号
+            phoneCode: inputPhone, // 测试环境:用输入的手机号
             nickname: this.nickname || '用户',
             avatar: this.avatar || ''
           })
@@ -265,9 +287,17 @@ uni.showToast({ title: '登录成功', icon: 'success' })
 
           uni.showToast({ title: '登录成功', icon: 'success' })
 
-          // 根据角色跳转到对应首页
+          // 根据角色跳转到对应首页,或进入信息编辑页面
           setTimeout(() => {
-            this.navigateToHome(res.data.role)
+            if (res.data.needsRoleSelect) {
+              // 新用户需要选择角色
+              uni.redirectTo({
+                url: `/pages/user-edit/user-edit?token=${res.data.token}&userId=${res.data.userId}&needsRoleSelect=true`
+              })
+            } else {
+              // 老用户直接进入首页
+              this.navigateToHome(res.data.role)
+            }
           }, 1000)
         } catch (e) {
           uni.showToast({ title: e.message || '登录失败', icon: 'none' })

+ 59 - 172
zxyj-frontend/pages/profile/profile.vue

@@ -6,54 +6,55 @@
         <view class="nickname">{{ nickname || '未设置昵称' }}</view>
         <view class="role">{{ role === 'parent' ? '家长模式' : (role === 'child' ? '孩子模式' : '指导师模式') }}</view>
       </view>
+      <button class="btn-switch" @click="switchMode">切换</button>
     </view>
 
-	<view class="menu-list">
-		<!-- 修改个人资料:所有角色可见 -->
-		<view class="menu-item" @click="goToEditInfo">
-			<text>📝 修改个人资料</text>
-			<text class="arrow">›</text>
-		</view>
-		<!-- 我的孩子:仅家长可见 -->
-		<view class="menu-item" v-if="role === 'parent'" @click="goToChildren">
-			<text>👶 我的孩子</text>
-			<text class="arrow">›</text>
-		</view>
-		<view class="menu-item" @click="goToPointsLogs">
-			<text>📊 积分记录</text>
-			<text class="arrow">›</text>
-		</view>
-		<!-- 设置/重置密码:仅家长和指导师可见,孩子不可见 -->
-		<view class="menu-item" v-if="role !== 'child'" @click="showPasswordModal = true">
-			<text>🔐 {{ hasPassword ? '重置密码' : '设置密码' }}</text>
-			<text class="arrow">›</text>
-		</view>
-		<view class="menu-item" @click="logout">
-			<text>🚪 退出登录</text>
-			<text class="arrow">›</text>
-		</view>
-	</view>
+    <view class="menu-list">
+      <!-- 修改个人资料:所有角色可见 -->
+      <view class="menu-item" @click="goToEditInfo">
+        <text>📝 修改个人资料</text>
+        <text class="arrow">›</text>
+      </view>
+      <!-- 我的孩子:仅家长可见 -->
+      <view class="menu-item" v-if="role === 'parent'" @click="goToChildren">
+        <text>👶 我的孩子</text>
+        <text class="arrow">›</text>
+      </view>
+      <view class="menu-item" @click="goToPointsLogs">
+        <text>📊 积分记录</text>
+        <text class="arrow">›</text>
+      </view>
+      <!-- 设置/重置密码:仅家长和指导师可见,孩子不可见 -->
+      <view class="menu-item" v-if="role !== 'child'" @click="showPasswordModal = true">
+        <text>🔐 {{ hasPassword ? '重置密码' : '设置密码' }}</text>
+        <text class="arrow">›</text>
+      </view>
+      <view class="menu-item" @click="logout">
+        <text>🚪 退出登录</text>
+        <text class="arrow">›</text>
+      </view>
+    </view>
 
-	<!-- 设置/重置密码弹窗 -->
-	<view class="modal-mask" v-if="showPasswordModal" @click="showPasswordModal = false">
-		<view class="modal" @click.stop>
-			<view class="modal-title">{{ hasPassword ? '重置密码' : '设置密码' }}</view>
-			<!-- 重置密码时需要先验证旧密码 -->
-			<view class="form-item" v-if="hasPassword">
-				<input v-model="oldPassword" type="number" password placeholder="输入旧密码" maxlength="6" />
-			</view>
-			<view class="form-item">
-				<input v-model="newPassword" type="number" password placeholder="输入新密码" maxlength="6" />
-			</view>
-			<view class="form-item">
-				<input v-model="confirmPassword" type="number" password placeholder="确认新密码" maxlength="6" />
-			</view>
-			<view class="modal-btns">
-				<button @click="showPasswordModal = false">取消</button>
-				<button class="btn-primary" @click="savePassword">确定</button>
-			</view>
-		</view>
-	</view>
+    <!-- 设置/重置密码弹窗 -->
+    <view class="modal-mask" v-if="showPasswordModal" @click="showPasswordModal = false">
+      <view class="modal" @click.stop>
+        <view class="modal-title">{{ hasPassword ? '重置密码' : '设置密码' }}</view>
+        <view class="form-item" v-if="hasPassword">
+          <input v-model="oldPassword" type="number" password placeholder="输入旧密码" maxlength="6" />
+        </view>
+        <view class="form-item">
+          <input v-model="newPassword" type="number" password placeholder="输入新密码" maxlength="6" />
+        </view>
+        <view class="form-item">
+          <input v-model="confirmPassword" type="number" password placeholder="确认新密码" maxlength="6" />
+        </view>
+        <view class="modal-btns">
+          <button @click="showPasswordModal = false">取消</button>
+          <button class="btn-primary" @click="savePassword">确定</button>
+        </view>
+      </view>
+    </view>
+  </view>
 </template>
 
 <script>
@@ -64,20 +65,18 @@ export default {
     return {
       nickname: '',
       role: 'parent',
-      hasPassword: false, // 是否已设置过密码
+      hasPassword: false,
       showPasswordModal: false,
-      oldPassword: '', // 旧密码(重置时用)
-      newPassword: '', // 新密码
-      confirmPassword: '', // 确认密码
-      children: [] // 孩子列表
+      oldPassword: '',
+      newPassword: '',
+      confirmPassword: '',
+      children: []
     }
   },
   onShow() {
-    this.nickname = this.$store.state.nickname
-    this.role = this.$store.state.role
-    // 检查是否已设置密码(通过检查本地存储或调用API)
+    this.nickname = this.$store.state.nickname || uni.getStorageSync('userInfo')?.nickname || ''
+    this.role = this.$store.state.role || 'parent'
     this.hasPassword = !!uni.getStorageSync('passwordSet')
-    // 加载孩子列表(用于切换模式时判断)
     this.loadChildren()
   },
   methods: {
@@ -98,17 +97,7 @@ export default {
     goToPointsLogs() {
       uni.navigateTo({ url: '/pages/points/points' })
     },
-  methods: {
-    async loadChildren() {
-      try {
-        const res = await getChildren()
-        this.children = res.data || []
-      } catch (e) {
-        console.error('获取孩子列表失败', e)
-      }
-    },
     switchMode() {
-      // 孩子切换到家长需要验证密码
       if (this.role === 'child') {
         this.verifyPasswordForSwitch(() => {
           this.doSwitchMode()
@@ -117,23 +106,12 @@ export default {
         this.doSwitchMode()
       }
     },
-    async doSwitchMode() {
+    doSwitchMode() {
       const newRole = this.role === 'parent' ? 'child' : 'parent'
-      
-      // 家长切换到孩子模式时,检查是否有孩子
       if (newRole === 'child') {
-        // 如果孩子列表为空,重新加载
         if (this.children.length === 0) {
-          try {
-            const res = await getChildren()
-            this.children = res.data || []
-          } catch (e) {
-            uni.showToast({ title: '获取孩子信息失败', icon: 'none' })
-            return
-          }
+          this.loadChildren()
         }
-        
-        // 如果没有孩子,提示用户添加
         if (this.children.length === 0) {
           uni.showModal({
             title: '提示',
@@ -146,91 +124,28 @@ export default {
           })
           return
         }
-        
-        // 如果只有一个孩子,直接切换
         if (this.children.length === 1) {
           this.$store.commit('switchRole', newRole)
           this.role = newRole
-          uni.showToast({
-            title: '已切换为孩子模式',
-            icon: 'success'
-          })
+          uni.showToast({ title: '已切换为孩子模式', icon: 'success' })
           return
         }
-        
-        // 如果有多个孩子,让孩子选择
         uni.showActionSheet({
           itemList: this.children.map(c => c.nickname),
           success: (res) => {
             const selectedChild = this.children[res.tapIndex]
-            // 存储选中的孩子ID
             uni.setStorageSync('currentChildId', selectedChild.id)
             this.$store.commit('switchRole', newRole)
             this.role = newRole
-            uni.showToast({
-              title: `已切换为${selectedChild.nickname}模式`,
-              icon: 'success'
-            })
+            uni.showToast({ title: `已切换为${selectedChild.nickname}模式`, icon: 'success' })
           }
         })
         return
       }
-      
-      // 其他情况直接切换
-      this.$store.commit('switchRole', newRole)
-      this.role = newRole
-      uni.showToast({
-        title: `已切换为${newRole === 'parent' ? '家长' : '孩子'}模式`,
-        icon: 'success'
-      })
-  },
-  onShow() {
-    this.nickname = this.$store.state.nickname
-    this.role = this.$store.state.role
-    // 检查是否已设置密码(通过检查本地存储或调用 API)
-    this.hasPassword = !!uni.getStorageSync('passwordSet')
-    // 加载孩子列表(用于判断是否有孩子)
-    this.loadChildren()
-  },
-  methods: {
-    async loadChildren() {
-      try {
-        const res = await getChildren()
-        this.children = res.data || []
-      } catch (e) {
-        console.error('获取孩子列表失败', e)
-      }
-    },
-    goToEditInfo() {
-      uni.navigateTo({ url: '/pages/user-edit/user-edit' })
-    },
-    goToChildren() {
-      uni.navigateTo({ url: '/pages/profile/children' })
-    },
-    goToPointsLogs() {
-      uni.navigateTo({ url: '/pages/points/points' })
-    },
-  methods: {
-    switchMode() {
-      // 孩子切换到家长需要验证密码
-      if (this.role === 'child') {
-        this.verifyPasswordForSwitch(() => {
-          this.doSwitchMode()
-        })
-      } else {
-        this.doSwitchMode()
-      }
-    },
-    doSwitchMode() {
-      const newRole = this.role === 'parent' ? 'child' : 'parent'
       this.$store.commit('switchRole', newRole)
       this.role = newRole
-      uni.showToast({
-        title: `已切换为${newRole === 'parent' ? '家长' : '孩子'}模式`,
-        icon: 'success'
-      })
+      uni.showToast({ title: `已切换为${newRole === 'parent' ? '家长' : '孩子'}模式`, icon: 'success' })
     },
-    // 验证密码(用于切换模式)
     verifyPasswordForSwitch(callback) {
       uni.showModal({
         title: '验证密码',
@@ -252,15 +167,6 @@ export default {
         }
       })
     },
-    goToEditInfo() {
-      uni.navigateTo({ url: '/pages/user-edit/user-edit' })
-    },
-    goToChildren() {
-      uni.navigateTo({ url: '/pages/profile/children' })
-    },
-    goToPointsLogs() {
-      uni.navigateTo({ url: '/pages/points/points' })
-    },
     async savePassword() {
       if (this.newPassword.length !== 6) {
         uni.showToast({ title: '请输入6位密码', icon: 'none' })
@@ -270,14 +176,12 @@ export default {
         uni.showToast({ title: '两次密码不一致', icon: 'none' })
         return
       }
-      // 重置密码需要验证旧密码
       if (this.hasPassword && this.oldPassword.length !== 6) {
         uni.showToast({ title: '请输入旧密码', icon: 'none' })
         return
       }
       try {
         if (this.hasPassword) {
-          // 验证旧密码
           const verifyRes = await verifyPassword(this.oldPassword)
           if (verifyRes.code !== 200) {
             uni.showToast({ title: '旧密码错误', icon: 'none' })
@@ -316,7 +220,6 @@ export default {
 .container {
   padding: 30rpx;
 }
-
 .user-card {
   background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
   border-radius: 20rpx;
@@ -325,26 +228,21 @@ export default {
   align-items: center;
   color: #fff;
 }
-
 .avatar {
   font-size: 80rpx;
   margin-right: 30rpx;
 }
-
 .user-info {
   flex: 1;
 }
-
 .nickname {
   font-size: 36rpx;
   font-weight: bold;
 }
-
 .role {
   font-size: 24rpx;
   opacity: 0.8;
 }
-
 .btn-switch {
   background: rgba(255, 255, 255, 0.2);
   color: #fff;
@@ -352,13 +250,11 @@ export default {
   padding: 10rpx 20rpx;
   border-radius: 20rpx;
 }
-
 .menu-list {
   margin-top: 30rpx;
   background: #fff;
   border-radius: 16rpx;
 }
-
 .menu-item {
   padding: 30rpx;
   border-bottom: 1rpx solid #f0f0f0;
@@ -366,15 +262,12 @@ export default {
   justify-content: space-between;
   font-size: 28rpx;
 }
-
 .menu-item:last-child {
   border-bottom: none;
 }
-
 .arrow {
   color: #ccc;
 }
-
 .modal-mask {
   position: fixed;
   top: 0;
@@ -386,39 +279,33 @@ export default {
   align-items: center;
   justify-content: center;
 }
-
 .modal {
   background: #fff;
   border-radius: 20rpx;
   padding: 40rpx;
   width: 80%;
 }
-
 .modal-title {
   font-size: 32rpx;
   font-weight: bold;
   margin-bottom: 30rpx;
   text-align: center;
 }
-
 .form-item {
   margin-bottom: 20rpx;
 }
-
 .form-item input {
   border: 1rpx solid #ddd;
   border-radius: 10rpx;
   padding: 20rpx;
   font-size: 28rpx;
 }
-
 .modal-btns {
   display: flex;
   gap: 20rpx;
   margin-top: 30rpx;
 }
-
 .modal-btns button {
   flex: 1;
 }
-</style>
+</style>

+ 74 - 5
zxyj-frontend/pages/user-edit/user-edit.vue

@@ -130,8 +130,8 @@
 			</view>
 		</view>
 
-		<!-- 家庭绑定(非指导师显示) -->
-		<view class="family-section" v-if="isEdit && form.role !== 'teacher'">
+		<!-- 家庭绑定(新用户注册时显示) -->
+		<view class="family-section" v-if="isEdit && form.role !== 'teacher' && isNewUserFlow">
 			<view class="section-title">家庭绑定</view>
 
 			<!-- 邀请码输入 -->
@@ -153,8 +153,17 @@
 			</view>
 		</view>
 
+		<!-- 修改个人资料保存按钮(非新用户流程时显示) -->
+		<view class="family-section" v-if="isEdit && !isNewUserFlow && form.role !== 'teacher'">
+			<view class="family-actions">
+				<button class="btn btn-primary" @click="saveUserInfo" :loading="loading">
+					保存修改
+				</button>
+			</view>
+		</view>
+
 		<!-- 指导师提交按钮 -->
-		<view class="family-section" v-if="isEdit && form.role === 'teacher'">
+		<view class="family-section" v-if="isEdit && form.role === 'teacher' && isNewUserFlow">
 			<view class="family-actions">
 				<button class="btn btn-primary" @click="submitTeacherInfo" :loading="loading">
 					提交申请
@@ -165,13 +174,14 @@
 </template>
 
 <script>
-import { updateUserInfo, joinFamily, createFamily, directRegister } from '../../utils/api.js'
+import { updateUserInfo, joinFamily, createFamily, directRegister, getUserInfo } from '../../utils/api.js'
 
 export default {
 	data() {
 		return {
 			isEdit: false,
 			showRoleSelect: true,
+			isNewUserFlow: false,  // 是否是新用户注册流程
 			form: {
 				role: '',
 				avatar: '',
@@ -196,15 +206,27 @@ export default {
 			this.token = options.token
 			this.userId = options.userId
 			
-			// 检查是否需要选择角色(新用户)
+			// 检查是否需要选择角色(新用户)- 只有明确传递 needsRoleSelect=true 才显示选择
 			this.showRoleSelect = options.needsRoleSelect === 'true'
 			this.isEdit = !this.showRoleSelect
+			this.isNewUserFlow = options.needsRoleSelect === 'true'
 		} else if (options.phone && options.needsRoleSelect === 'true') {
 			// 测试环境:没有token,只有手机号,是新用户注册流程
 			this.showRoleSelect = true
 			this.isEdit = false
+			this.isNewUserFlow = true
 			// 将手机号传递给表单
 			this.form.phone = options.phone
+		} else {
+			// 其他情况:默认进入编辑模式(如从"我的"页面进入)
+			this.showRoleSelect = false
+			this.isEdit = true
+			this.isNewUserFlow = false
+		}
+		
+		// 非新用户流程时,加载已有用户信息
+		if (this.isEdit && !this.isNewUserFlow) {
+			this.loadUserInfo()
 		}
 	},
 	methods: {
@@ -264,6 +286,53 @@ export default {
 		onBirthdayChange(e) {
 			this.form.birthday = e.detail.value
 		},
+		// 加载已有用户信息
+		async loadUserInfo() {
+			try {
+				const res = await getUserInfo()
+				if (res.code === 200 && res.data) {
+					const userData = res.data
+					this.form.nickname = userData.nickname || ''
+					this.form.realName = userData.realName || ''
+					this.form.gender = userData.gender || ''
+					this.form.idCard = userData.idCard || ''
+					// 生日只取日期部分,去掉时间
+					this.form.birthday = userData.birthday ? userData.birthday.split(' ')[0] : ''
+					this.form.avatar = userData.avatar || ''
+					this.form.role = userData.role || ''
+					this.form.phone = userData.phone || ''
+				}
+			} catch (e) {
+				console.error('加载用户信息失败', e)
+			}
+		},
+		// 保存修改
+		async saveUserInfo() {
+			if (!this.form.nickname) {
+				uni.showToast({ title: '请输入昵称', icon: 'none' })
+				return
+			}
+			
+			this.loading = true
+			try {
+				await updateUserInfo({
+					nickname: this.form.nickname,
+					realName: this.form.realName,
+					gender: this.form.gender,
+					idCard: this.form.idCard,
+					birthday: this.form.birthday,
+					avatar: this.form.avatar
+				})
+				uni.showToast({ title: '保存成功', icon: 'success' })
+				setTimeout(() => {
+					uni.navigateBack()
+				}, 1500)
+			} catch (e) {
+				uni.showToast({ title: '保存失败', icon: 'none' })
+			} finally {
+				this.loading = false
+			}
+		},
 		async submitTeacherInfo() {
 			// 验证必填字段
 			if (!this.form.nickname) {

+ 17 - 0
zxyj-web/src/api/operationLog.js

@@ -0,0 +1,17 @@
+import request from '@/utils/request'
+
+export function getOperationLogs(params) {
+  return request({
+    url: '/api/admin/logs/operations',
+    method: 'get',
+    params
+  })
+}
+
+export function getRecentLogs(userId, limit = 10) {
+  return request({
+    url: `/api/admin/logs/recent/${userId}`,
+    method: 'get',
+    params: { limit }
+  })
+}

+ 6 - 0
zxyj-web/src/router/index.js

@@ -91,6 +91,12 @@ const routes = [
         name: 'PackageAudit',
         component: () => import('@/views/PackageAudit.vue'),
         meta: { title: '套餐审核' }
+      },
+      {
+        path: 'operation-logs',
+        name: 'OperationLogs',
+        component: () => import('@/views/OperationLogs.vue'),
+        meta: { title: '操作日志' }
       }
     ]
   }

+ 4 - 0
zxyj-web/src/views/Layout.vue

@@ -91,6 +91,10 @@
               <i class="el-icon-s-check"></i>
               <span>套餐审核</span>
             </el-menu-item>
+            <el-menu-item index="/operation-logs">
+              <i class="el-icon-s-order"></i>
+              <span>操作日志</span>
+            </el-menu-item>
           </el-submenu>
         </el-menu>
     </el-aside>

+ 209 - 0
zxyj-web/src/views/OperationLogs.vue

@@ -0,0 +1,209 @@
+<template>
+  <div class="operation-logs">
+    <!-- 筛选条件 -->
+    <el-card class="filter-card">
+      <el-form :inline="true" :model="filterForm" class="filter-form">
+        <el-form-item label="用户ID">
+          <el-input v-model="filterForm.userId" placeholder="请输入用户ID" clearable></el-input>
+        </el-form-item>
+        <el-form-item label="操作类型">
+          <el-select v-model="filterForm.operationType" placeholder="请选择" clearable>
+            <el-option label="登录" value="login"></el-option>
+            <el-option label="登出" value="logout"></el-option>
+            <el-option label="创建" value="create"></el-option>
+            <el-option label="更新" value="update"></el-option>
+            <el-option label="删除" value="delete"></el-option>
+            <el-option label="查询" value="query"></el-option>
+          </el-select>
+        </el-form-item>
+        <el-form-item label="时间范围">
+          <el-date-picker
+            v-model="dateRange"
+            type="daterange"
+            range-separator="至"
+            start-placeholder="开始日期"
+            end-placeholder="结束日期"
+            value-format="yyyy-MM-dd">
+          </el-date-picker>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleQuery" :loading="loading">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- 日志列表 -->
+    <el-card>
+      <el-table :data="logs" v-loading="loading" stripe>
+        <el-table-column prop="id" label="ID" width="80"></el-table-column>
+        <el-table-column prop="userId" label="用户ID" width="100"></el-table-column>
+        <el-table-column prop="nickname" label="用户名" width="120"></el-table-column>
+        <el-table-column prop="operationType" label="操作类型" width="100">
+          <template slot-scope="scope">
+            <el-tag :type="getOperationTypeTag(scope.row.operationType)" size="small">
+              {{ scope.row.operationType }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="description" label="操作描述" min-width="200"></el-table-column>
+        <el-table-column prop="requestPath" label="请求路径" min-width="180"></el-table-column>
+        <el-table-column prop="requestMethod" label="方法" width="80"></el-table-column>
+        <el-table-column prop="responseStatus" label="状态" width="80">
+          <template slot-scope="scope">
+            <el-tag :type="scope.row.responseStatus === 200 ? 'success' : 'danger'" size="small">
+              {{ scope.row.responseStatus }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="clientIp" label="IP地址" width="130"></el-table-column>
+        <el-table-column prop="createdAt" label="操作时间" width="160">
+          <template slot-scope="scope">
+            {{ formatDate(scope.row.createdAt) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="80" fixed="right">
+          <template slot-scope="scope">
+            <el-button type="text" size="small" @click="handleViewDetail(scope.row)">
+              详情
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <!-- 分页 -->
+      <el-pagination
+        @current-change="handlePageChange"
+        :current-page="pagination.page"
+        :page-size="pagination.size"
+        :total="pagination.total"
+        layout="total, prev, pager, next"
+        class="pagination">
+      </el-pagination>
+    </el-card>
+
+    <!-- 详情对话框 -->
+    <el-dialog title="操作日志详情" :visible.sync="detailVisible" width="600px">
+      <el-descriptions :column="1" border v-if="currentLog">
+        <el-descriptions-item label="ID">{{ currentLog.id }}</el-descriptions-item>
+        <el-descriptions-item label="用户ID">{{ currentLog.userId }}</el-descriptions-item>
+        <el-descriptions-item label="用户名">{{ currentLog.nickname }}</el-descriptions-item>
+        <el-descriptions-item label="操作类型">{{ currentLog.operationType }}</el-descriptions-item>
+        <el-descriptions-item label="操作描述">{{ currentLog.description }}</el-descriptions-item>
+        <el-descriptions-item label="请求路径">{{ currentLog.requestPath }}</el-descriptions-item>
+        <el-descriptions-item label="请求方法">{{ currentLog.requestMethod }}</el-descriptions-item>
+        <el-descriptions-item label="请求参数">{{ currentLog.requestParams || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="响应状态">{{ currentLog.responseStatus }}</el-descriptions-item>
+        <el-descriptions-item label="客户端IP">{{ currentLog.clientIp }}</el-descriptions-item>
+        <el-descriptions-item label="User-Agent">{{ currentLog.userAgent || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="操作时间">{{ formatDate(currentLog.createdAt) }}</el-descriptions-item>
+      </el-descriptions>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import axios from '@/utils/request'
+import { getOperationLogs } from '@/api/operationLog'
+
+export default {
+  name: 'OperationLogs',
+  data() {
+    return {
+      loading: false,
+      logs: [],
+      filterForm: {
+        userId: null,
+        operationType: null
+      },
+      dateRange: null,
+      pagination: {
+        page: 1,
+        size: 20,
+        total: 0
+      },
+      detailVisible: false,
+      currentLog: null
+    }
+  },
+  mounted() {
+    this.fetchLogs()
+  },
+  methods: {
+    async fetchLogs() {
+      this.loading = true
+      try {
+        const params = {
+          page: this.pagination.page,
+          size: this.pagination.size,
+          userId: this.filterForm.userId || undefined,
+          operationType: this.filterForm.operationType || undefined,
+          startDate: this.dateRange ? this.dateRange[0] : undefined,
+          endDate: this.dateRange ? this.dateRange[1] : undefined
+        }
+        const res = await getOperationLogs(params)
+        if (res.code === 200) {
+          this.logs = res.data.records || []
+          this.pagination.total = res.data.total || 0
+        }
+      } catch (e) {
+        this.$message.error('获取日志失败')
+      } finally {
+        this.loading = false
+      }
+    },
+    handleQuery() {
+      this.pagination.page = 1
+      this.fetchLogs()
+    },
+    handleReset() {
+      this.filterForm = { userId: null, operationType: null }
+      this.dateRange = null
+      this.pagination.page = 1
+      this.fetchLogs()
+    },
+    handlePageChange(page) {
+      this.pagination.page = page
+      this.fetchLogs()
+    },
+    handleViewDetail(row) {
+      this.currentLog = row
+      this.detailVisible = true
+    },
+    getOperationTypeTag(type) {
+      const tags = {
+        login: 'success',
+        logout: 'info',
+        create: 'primary',
+        update: 'warning',
+        delete: 'danger',
+        query: ''
+      }
+      return tags[type] || ''
+    },
+    formatDate(dateStr) {
+      if (!dateStr) return '-'
+      return new Date(dateStr).toLocaleString('zh-CN')
+    }
+  }
+}
+</script>
+
+<style scoped>
+.operation-logs {
+  padding: 20px;
+}
+
+.filter-card {
+  margin-bottom: 20px;
+}
+
+.filter-form {
+  margin-bottom: 0;
+}
+
+.pagination {
+  margin-top: 20px;
+  text-align: right;
+}
+</style>