Browse Source

fix: 修复UserService.createChild空指针异常

- dto.getAge()可能为空时导致NullPointerException
- 添加年龄空值检查

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
User 6 tháng trước cách đây
mục cha
commit
7cd7e05dc7

+ 439 - 21
docs/task-requirements.md

@@ -5,7 +5,7 @@
 
 ---
 
-## 一、需求清单总览
+## 一、需求清单概览
 
 | 需求ID | 描述 | 优先级 | 状态 |
 |--------|------|--------|------|
@@ -33,7 +33,7 @@
 
 ---
 
-## 二、需求详细说明
+## 二、需求详细描述
 
 ### 2.1 任务创建(TASK-001~005)
 
@@ -70,15 +70,15 @@
 
 ---
 
-### 2.2 任务模板和审核(TASK-006~007)
+### 2.2 任务模板与审核(TASK-006~007)
 
 #### TASK-006: 任务模板功能
-- **描述**: 使用任务模板快速创建
+- **描述**: 通过任务模板快速创建
 - **状态**: ⚠️ 部分实现
 - **待完善**: 模板库管理、模板选择UI
 
 #### TASK-007: 设置审核方式
-- **描述**: 选择审核人类型
+- **描述**: 设置审核人类型
 - **字段**: `reviewType` (String)
 - **可选值**: 
   - `creator` - 创建者审核
@@ -88,12 +88,12 @@
 
 ---
 
-### 2.3 任务查看和完成(TASK-008~011)
+### 2.3 任务查看与完成(TASK-008~011)
 
 #### TASK-008: 孩子查看今日任务
-- **描述**: 孩子可以查看今日需要完成的任务列表
+- **描述**: 孩子可查看今日需完成的任务列表
 - **实现**: `GET /api/tasks/today?childId={id}`
-- **返回**: 当日所有pending/completed状态的任务
+- **返回**: 当日所有 pending / completed 状态的任务
 
 #### TASK-009: 孩子完成任务
 - **描述**: 孩子点击完成按钮提交任务
@@ -129,17 +129,17 @@
 
 ---
 
-### 2.5 任务提醒和统计(TASK-013~016)
+### 2.5 任务提醒与统计(TASK-013~016)
 
 #### TASK-013: 任务提醒通知
-- **描述**: 任务截止前发送提醒
+- **描述**: 在任务截止前发送提醒
 - **API**:
   - `GET /api/tasks/reminder/{childId}` - 30分钟内到期
   - `GET /api/tasks/upcoming/{childId}?minutes=60` - 自定义时间
   - `GET /api/tasks/overdue/{childId}` - 已超时任务
 
 #### TASK-014: 任务历史记录查询
-- **描述**: 查看历史任务,支持筛选
+- **描述**: 查看历史任务并支持筛选
 - **API**: `GET /api/tasks/history/filter/{childId}`
 - **筛选条件**: 状态、时间范围
 
@@ -169,31 +169,31 @@ private Long creatorId;      // 创建者ID
 private String title;        // 任务名称
 private String description;  // 任务描述
 
-// 执行者
+// 执行者信息
 private String executorType; // child/parent
 private Long executorId;     // 执行者ID
 private Long childId;        // 孩子ID(兼容)
 
-// 积分和时间
+// 积分与时间
 private Integer points;      // 积分值
 private Date deadline;      // 截止时间
 
-// 分类和审核
+// 分类与审核
 private String category;     // 分类
 private Integer needReview; // 是否需要审核
 private String reviewType;   // 审核人类型
 private String status;       // 任务状态
 private Date completedAt;    // 完成时间
 
-// 重复设置 (TASK-004)
+// 重复设置(TASK-004)
 private String repeatType;  // none/daily/weekly
 private Long templateId;    // 模板ID
 private Integer isTemplate; // 是否模板
 
-// POINT-002: 超额积分
+// POINT-002:超额积分
 private String bonusRule;   // JSON格式规则
 
-// POINT-009: 积分有效期
+// POINT-009:积分有效期
 private Integer pointsExpireDays; // 有效期天数
 
 // 扩展字段
@@ -205,7 +205,7 @@ private Date earliestStart;  // 最早开始
 private Date latestEnd;      // 最晚结束
 ```
 
-### 3.2 状态流转
+### 3.2 状态流转说明
 
 ```
 pending → completed (孩子完成)
@@ -214,6 +214,424 @@ completed → pending (审核拒绝)
 pending → cancelled (创建者删除)
 ```
 
+### 3.3 任务属性分组与职责边界
+
+> 说明:以下内容从业务视角定义任务对象的完整属性。前端展示时,应按角色和阶段裁剪字段;后端存储时,宜将“任务定义”和“执行记录”分开管理。
+
+#### A. 基础信息
+
+| 字段 | 含义 | 创建时 | 执行时 | 审核时 | 完成后 |
+|------|------|--------|--------|--------|--------|
+| title | 任务标题 | 必填,可编辑 | 只读 | 只读 | 只读 |
+| description | 任务描述 | 可选,可编辑 | 只读 | 只读 | 只读 |
+| category | 任务分类 | 必填,可编辑 | 只读 | 只读 | 只读 |
+| taskType | 任务类型(一次性/重复) | 必填,可编辑 | 只读 | 只读 | 只读 |
+| templateId | 模板来源 | 选择模板时自动带入 | 只读 | 只读 | 只读 |
+
+#### B. 执行对象
+
+| 字段 | 含义 | 创建时 | 执行时 | 审核时 | 完成后 |
+|------|------|--------|--------|--------|--------|
+| creatorId | 创建人 | 系统生成 | 只读 | 只读 | 只读 |
+| familyId | 所属家庭 | 系统生成 | 只读 | 只读 | 只读 |
+| executorType | 执行者类型 | 必填,可编辑 | 只读 | 只读 | 只读 |
+| executorId / childId | 执行者ID/孩子ID | 必填,可编辑 | 只读 | 只读 | 只读 |
+
+#### C. 规则信息
+
+| 字段 | 含义 | 创建时 | 执行时 | 审核时 | 完成后 |
+|------|------|--------|--------|--------|--------|
+| points | 奖励积分 | 必填,可编辑 | 只读 | 只读 | 只读 |
+| deadline | 截止时间 | 必填,可编辑 | 只读 | 只读 | 只读 |
+| earliestStart | 最早开始时间 | 可选,可编辑 | 只读 | 只读 | 只读 |
+| latestEnd | 最晚结束时间 | 可选,可编辑 | 只读 | 只读 | 只读 |
+| duration | 预计时长 | 可选,可编辑 | 只读 | 只读 | 只读 |
+| completeTypes | 完成方式 | 必填,可编辑 | 只读 | 只读 | 只读 |
+| needReview | 是否需要审核 | 必填,可编辑 | 只读 | 只读 | 只读 |
+| reviewType | 审核方式 | 需要审核时可选 | 只读 | 只读 | 只读 |
+| repeatType | 重复规则 | 选择重复任务时可选 | 只读 | 只读 | 只读 |
+| bonusRule | 超额积分规则 | 可选,可编辑 | 只读 | 只读 | 只读 |
+| pointsExpireDays | 积分有效期 | 可选,可编辑 | 只读 | 只读 | 只读 |
+
+#### D. 执行与提交信息
+
+| 字段 | 含义 | 创建时 | 执行时 | 审核时 | 完成后 |
+|------|------|--------|--------|--------|--------|
+| status | 当前状态 | 系统初始化 | 可随流程变化 | 可随流程变化 | 归档只读 |
+| completedAt | 完成时间 | 为空 | 记录提交或完成时间 | 可查看 | 只读 |
+| completedBy | 实际完成人 | 为空 | 孩子提交时生成 | 可查看 | 只读 |
+| completeContent | 完成说明/文本 | 为空 | 孩子填写 | 可查看 | 只读 |
+| completeMedia | 完成附件(图/音/视频) | 为空 | 孩子上传 | 可查看 | 只读 |
+| overtimeFlag | 超时标记 | 系统计算 | 可刷新 | 可查看 | 只读 |
+
+#### E. 审核信息
+
+| 字段 | 含义 | 创建时 | 执行时 | 审核时 | 完成后 |
+|------|------|--------|--------|--------|--------|
+| reviewerId | 审核人ID | 为空 | 只读 | 家长/指导师填写 | 只读 |
+| reviewTime | 审核时间 | 为空 | 只读 | 审核通过时生成 | 只读 |
+| reviewResult | 审核结果 | 为空 | 只读 | 通过/驳回/退回补充 | 只读 |
+| reviewComment | 审核意见 | 为空 | 只读 | 可填写 | 只读 |
+
+#### F. 系统信息
+
+| 字段 | 含义 | 创建时 | 执行时 | 审核时 | 完成后 |
+|------|------|--------|--------|--------|--------|
+| id | 任务ID | 系统生成 | 只读 | 只读 | 只读 |
+| createdAt | 创建时间 | 系统生成 | 只读 | 只读 | 只读 |
+| updatedAt | 更新时间 | 系统生成 | 系统更新 | 系统更新 | 只读 |
+| isTemplate | 是否模板任务 | 可选 | 只读 | 只读 | 只读 |
+| templateId | 模板关联 | 可选 | 只读 | 只读 | 只读 |
+
+### 3.4 阶段展示、角色与字段变更规则
+
+#### 3.4.1 创建阶段
+
+- **可见角色**: 家长、管理员、指导师
+- **可编辑角色**: 任务创建人
+- **主要内容**: 标题、描述、分类、执行对象、积分、截止时间、时长、完成方式、是否审核、审核方式、重复规则、提醒规则、模板来源
+- **字段规则**:
+  - 发布前允许修改全部业务字段
+  - 发布后建议锁定积分、截止时间、执行对象、审核方式、重复规则,避免任务含义被改变
+
+#### 3.4.2 执行阶段
+
+- **可见角色**: 孩子、家长、指导师(按家庭权限)
+- **可编辑角色**: 孩子仅可填写提交内容;家长不可直接修改孩子的提交结果
+- **主要内容**: 任务标题、任务说明、剩余时间、奖励积分、完成要求、附件上传入口、提醒信息
+- **字段规则**:
+  - 任务定义字段只读
+  - 孩子可更新提交内容、补充说明、附件
+  - 系统记录实际开始时间、提交时间、超时标记
+
+#### 3.4.3 提交阶段
+
+- **可见角色**: 孩子、家长
+- **可编辑角色**: 孩子
+- **主要内容**: 提交说明、图片/视频/录音/文字、计时结果、是否完成
+- **字段规则**:
+  - 允许在审核前补充材料的,提交内容可再次编辑
+  - 审核提交后,提交内容进入只读,避免争议
+
+#### 3.4.4 审核阶段
+
+- **可见角色**: 家长、指导师
+- **可编辑角色**: 审核人
+- **主要内容**: 孩子的提交内容、完成时间、是否超时、历史修改记录、审核意见
+- **字段规则**:
+  - 审核人可填写审核结果与意见
+  - 审核通过后,任务结果和积分流水应固定,不再覆盖原记录
+  - 审核驳回时,应保留原提交内容和驳回原因
+
+#### 3.4.5 完成阶段
+
+- **可见角色**: 全部相关角色
+- **可编辑角色**: 原则上无;仅保留申诉或补审入口
+- **主要内容**: 最终状态、实际完成时间、获得积分、审核结果、审核意见、附件证据、历史记录
+- **字段规则**:
+  - 任务主体只读
+  - 若发生积分回滚或补审,必须保留操作流水
+
+### 3.5 推荐的状态流转说明
+
+为便于前端和接口统一理解,建议将业务流程描述为:
+
+```
+草稿 → 待执行 → 执行中 → 待提交 → 待审核 → 已完成
+                         ↘ 已驳回 / 已过期 / 已取消
+```
+
+- **草稿**: 仅创建人可见,未发布
+- **待执行**: 任务已发布,等待孩子开始
+- **执行中**: 孩子已开始执行或已领取任务
+- **待提交**: 已完成动作,等待提交结果
+- **待审核**: 已提交,等待家长/指导师确认
+- **已完成**: 审核通过或自动通过
+- **已驳回**: 审核不通过,等待补充或重做
+- **已过期**: 超过截止时间仍未完成
+- **已取消**: 创建人主动撤销
+
+### 3.6 Web端与小程序端字段展示矩阵
+
+> 说明:Web端以管理与配置为主,小程序端以查看与执行为主。下表用于统一页面展示边界,避免前后端对同一字段理解不一致。
+
+#### 3.6.1 任务主对象字段
+
+| 字段 | Web端任务列表 `Tasks.vue` | Web端模板页 `TaskTemplates.vue` | 小程序任务列表 `tasks.vue` | 小程序创建页 `create-task.vue` | 创建后可改 |
+|------|---------------------------|----------------------------------|-----------------------------|----------------------------------|-----------|
+| title | 显示 | 显示 | 显示 | 填写 | 否(发布后锁定) |
+| description | 不显示 | 编辑/显示 | 显示 | 填写 | 仅允许补充说明 |
+| category | 显示 | 显示/筛选 | 显示 | 填写 | 否 |
+| points | 显示 | 显示/编辑 | 显示 | 填写 | 不建议改 |
+| deadline | 显示 | 不直接展示 | 显示 | 填写 | 否 |
+| status | 显示 | 不展示 | 显示(完成/未完成) | 不展示 | 系统变更 |
+| needReview | 显示 | 显示/编辑 | 创建页可设 | 创建页可设 | 否 |
+| childId / childIds | 显示(childId) | 不展示 | 创建页选择孩子 | 创建页选择孩子 | 否 |
+| repeatType | 不展示 | 编辑 | 不展示 | 创建页可设 | 否 |
+| completeTypes | 不展示 | 编辑 | 创建页可设 | 创建页可设 | 否 |
+| duration | 不展示 | 显示/编辑 | 创建页可设 | 创建页可设 | 仅补充说明类变更 |
+| createdAt | 显示 | 显示 | 不展示 | 不展示 | 系统字段 |
+| templateId | 不展示 | 不直接展示 | 模板选择后自动带入 | 模板选择后自动带入 | 系统字段 |
+
+#### 3.6.2 Web端任务管理页显示重点
+
+- **任务列表页**: `id`、`title`、`points`、`category`、`deadline`、`status`、`needReview`、`childId`、`createdAt`
+- **筛选条件**: 任务标题、状态、家庭
+- **统计信息**: 总任务数、今日任务数
+- **适用场景**: 后台查看、检索、按家庭定位任务
+
+#### 3.6.3 Web端任务模板页显示重点
+
+- **列表字段**: 模板名称、分类、积分、难度、推荐年龄、时长、任务性质、是否需要审核、启用状态、创建时间
+- **编辑字段**: 标题、描述、分类、积分、难度、推荐年龄、预计时长、完成方式、任务性质、频率、周期次数、是否审核、审核方式、按分类审核
+- **适用场景**: 统一模板配置,供任务创建复用
+
+#### 3.6.4 小程序任务列表页显示重点
+
+- **孩子视图**: 任务标题、积分、截止时间、完成按钮、已完成标识
+- **家长视图**: 今日任务列表、添加任务入口、家长任务列表
+- **适用场景**: 任务执行、快速完成、家庭日常管理
+
+#### 3.6.5 小程序创建页显示重点
+
+- **基础字段**: 任务名称、任务描述、选择孩子、积分、分类、截止时间、重复设置、是否需要审核
+- **扩展字段**: 当任务模板可用时,可自动带入标题、描述、分类、积分、完成方式、审核设置等
+- **适用场景**: 快速创建单次或重复任务
+
+#### 3.6.6 字段展示规则建议
+
+- **管理端可见更多配置项**,如模板、难度、审核细则、启用开关
+- **小程序端优先展示执行所需信息**,避免把模板配置、规则细节全部暴露给孩子
+- **创建后高敏感业务字段尽量只读**,包括积分、截止时间、审核方式、重复规则和执行对象
+
+### 3.7 关键字段生命周期矩阵
+
+> 说明:本节描述字段在各阶段的写入主体、锁定时机和只读边界。其粒度高于 3.6,适合用于约束前端表单和后端接口。
+
+| 字段 | 创建阶段 | 执行阶段 | 提交阶段 | 审核阶段 | 完成阶段 |
+|------|----------|----------|----------|----------|----------|
+| title | 创建人填写 | 只读 | 只读 | 只读 | 只读 |
+| description | 创建人填写 | 只读 | 只读 | 只读 | 只读 |
+| category | 创建人填写 | 只读 | 只读 | 只读 | 只读 |
+| points | 创建人填写 | 只读 | 只读 | 只读 | 只读 |
+| deadline | 创建人填写 | 只读 | 只读 | 只读 | 只读 |
+| childId / childIds | 创建人选择 | 只读 | 只读 | 只读 | 只读 |
+| repeatType | 创建人选择 | 只读 | 只读 | 只读 | 只读 |
+| completeTypes | 创建人选择 | 只读 | 只读 | 只读 | 只读 |
+| needReview | 创建人选择 | 只读 | 只读 | 只读 | 只读 |
+| reviewType | 创建人选择 | 只读 | 只读 | 只读 | 只读 |
+| duration | 创建人填写 | 只读 | 只读 | 只读 | 只读 |
+| earliestStart | 创建人填写 | 只读 | 只读 | 只读 | 只读 |
+| latestEnd | 创建人填写 | 只读 | 只读 | 只读 | 只读 |
+| status | 系统初始化 | 系统更新 | 系统更新 | 系统更新 | 归档只读 |
+| completedAt | 为空 | 执行开始时可为空 | 孩子提交时写入 | 审核时可查看 | 只读 |
+| completedBy | 为空 | 为空 | 孩子提交时写入 | 审核时可查看 | 只读 |
+| completeContent | 为空 | 孩子可填写 | 孩子可补充 | 审核人可查看 | 只读 |
+| completeMedia | 为空 | 孩子可上传 | 孩子可补充 | 审核人可查看 | 只读 |
+| overtimeFlag | 系统计算 | 系统更新 | 系统更新 | 审核时可查看 | 只读 |
+| reviewerId | 为空 | 只读 | 只读 | 审核人写入 | 只读 |
+| reviewTime | 为空 | 只读 | 只读 | 审核通过或驳回时写入 | 只读 |
+| reviewResult | 为空 | 只读 | 只读 | 审核人写入 | 只读 |
+| reviewComment | 为空 | 只读 | 只读 | 审核人写入 | 只读 |
+
+#### 3.7.1 生命周期规则
+
+- **创建阶段**: 任务定义字段应一次性写入,默认仅允许在发布前修改。
+- **执行阶段**: 任务定义应冻结,孩子仅执行任务,不得修改任务规则本身。
+- **提交阶段**: 仅允许写入完成内容、附件和提交时间,不得覆盖任务定义。
+- **审核阶段**: 仅允许写入审核人、审核结果和审核意见,提交内容应保留原始记录。
+- **完成阶段**: 任务主体和审核记录均应只读,仅保留申诉、补审或积分回滚入口。
+
+#### 3.7.2 推荐的写入边界
+
+- **任务定义类字段**:title、description、category、points、deadline、childId / childIds、repeatType、completeTypes、needReview、reviewType、duration、earliestStart、latestEnd。
+- **执行结果类字段**:status、completedAt、completedBy、completeContent、completeMedia、overtimeFlag。
+- **审核结果类字段**:reviewerId、reviewTime、reviewResult、reviewComment。
+- **系统字段**:id、createdAt、updatedAt、templateId、isTemplate。
+
+### 3.8 字段总览汇总表
+
+> 说明:本表适合用于评审、排期和页面对齐。它将“字段、含义、责任归属、端侧展示、可改阶段”合并呈现。
+
+| 字段 | 含义 | 责任归属 | Web端 | 小程序端 | 创建后是否可改 | 归属阶段 |
+|------|------|----------|------|---------|---------------|---------|
+| title | 任务名称 | 家长/创建人 | 列表显示 / 模板编辑 | 列表显示 / 创建页填写 | 否 | 创建 |
+| description | 任务描述 | 家长/创建人,孩子可补充 | 模板编辑 | 任务详情/创建页填写 | 仅补充说明 | 创建 / 提交 |
+| category | 任务分类 | 家长/创建人 | 列表显示 / 模板编辑 | 列表显示 / 创建页填写 | 否 | 创建 |
+| points | 奖励积分 | 家长/创建人 | 列表显示 / 模板编辑 | 列表显示 / 创建页填写 | 不建议改 | 创建 |
+| deadline | 截止时间 | 家长/创建人 | 列表显示 | 创建页填写 | 否 | 创建 |
+| childId / childIds | 执行孩子 | 家长/创建人 | 列表显示 childId | 创建页选择孩子 | 否 | 创建 |
+| repeatType | 重复规则 | 家长/创建人 | 模板编辑 | 创建页可设 | 否 | 创建 |
+| completeTypes | 完成方式 | 家长/创建人 | 模板编辑 | 创建页可设 | 否 | 创建 |
+| needReview | 是否审核 | 家长/创建人 | 列表显示 / 模板编辑 | 创建页可设 | 否 | 创建 |
+| reviewType | 审核方式 | 家长/创建人 | 模板编辑 | 创建页可设 | 否 | 创建 |
+| duration | 预计时长 | 家长/创建人 | 模板显示 / 编辑 | 创建页可设 | 仅补充说明类调整 | 创建 |
+| status | 当前状态 | 系统 | 列表显示 | 任务列表显示 | 系统变更 | 执行 / 审核 |
+| completedAt | 完成时间 | 系统 | 历史/详情 | 完成结果展示 | 否 | 提交 / 审核 |
+| completedBy | 完成人 | 孩子提交 | 历史/详情 | 完成结果展示 | 否 | 提交 |
+| completeContent | 完成说明 | 孩子提交,家长审核 | 详情/审核页 | 提交页填写 | 可在审核前补充 | 提交 |
+| completeMedia | 完成附件 | 孩子提交,家长审核 | 详情/审核页 | 提交页上传 | 可在审核前补充 | 提交 |
+| overtimeFlag | 超时标记 | 系统 | 列表/详情 | 列表/结果提示 | 系统变更 | 执行 / 提交 |
+| reviewerId | 审核人 | 审核人 | 审核页 | 不直接展示 | 否 | 审核 |
+| reviewTime | 审核时间 | 系统记录 | 审核页 | 结果展示 | 否 | 审核 |
+| reviewResult | 审核结果 | 审核人 | 审核页 | 结果展示 | 否 | 审核 |
+| reviewComment | 审核意见 | 审核人 | 审核页 | 结果展示 | 否 | 审核 |
+| templateId | 模板来源 | 系统 | 模板页关联 | 模板自动带入 | 否 | 创建 |
+| isTemplate | 模板标记 | 系统 | 模板页状态 | 不直接展示 | 否 | 创建 |
+| createdAt | 创建时间 | 系统 | 列表显示 | 不展示 | 否 | 系统 |
+| updatedAt | 更新时间 | 系统 | 列表/详情 | 不展示 | 否 | 系统 |
+| id | 任务ID | 系统 | 列表显示 | 不展示 | 否 | 系统 |
+
+#### 3.8.1 责任边界补充说明
+
+- **家长/创建人负责**: 任务定义字段的填写与首次发布,包括标题、描述、分类、积分、截止时间、执行孩子、重复规则、完成方式、审核方式和时长。
+- **孩子负责**: 提交执行结果,包括完成说明、附件和实际完成状态。
+- **审核人负责**: 输出审核结果、审核意见以及是否通过的最终判断。
+- **系统负责**: 状态流转、时间戳、超时标记、模板关联、创建时间和更新时间。
+
+### 3.9 阶段 × 角色 × 动作矩阵
+
+> 说明:本表用于统一“谁在什么阶段做什么”,避免文档仅描述字段而忽略流程责任。
+
+| 阶段 | 家长/创建人 | 孩子 | 审核人 | 系统 |
+|------|------------|------|--------|------|
+| 创建阶段 | 创建任务、选择孩子、设置积分/截止时间/完成方式/审核方式/重复规则、选择模板、发布任务 | 一般不参与;如有任务建议,仅提交申请不直接生效 | 可作为模板审核人或指定审核人,但不直接参与任务创建 | 校验必填项、生成任务ID、写入创建时间、初始化状态 |
+| 执行阶段 | 查看任务进度、必要时调整未发布任务;发布后原则上不改规则 | 查看任务、领取/开始任务、阅读执行要求 | 查看待审核任务预览,不修改任务定义 | 记录任务状态变化、计算超时、发送提醒 |
+| 提交阶段 | 查看孩子提交内容、等待审核 | 填写完成说明、上传图片/视频/录音、提交完成结果、必要时补充材料 | 查看提交内容,准备审核 | 记录提交时间、提交内容、附件信息、提交次数 |
+| 审核阶段 | 根据需要参与审核或查看审核结果 | 根据审核意见补充材料或重新提交 | 审核通过/驳回/退回补充,填写审核意见 | 记录审核结果、审核时间、审核人,触发积分变更 |
+| 完成阶段 | 查看最终结果、积分变更和历史记录 | 查看完成结果、获得积分或驳回原因 | 查看审核归档和历史记录 | 归档任务、保留流水、生成统计数据 |
+
+#### 3.9.1 动作规则说明
+
+- **创建阶段**: 仅家长/创建人可定义任务,孩子不直接参与任务生效。
+- **执行阶段**: 孩子为主要执行者,家长负责监督,系统负责提醒与超时判断。
+- **提交阶段**: 孩子负责提交,家长负责查看,系统负责记录提交证据。
+- **审核阶段**: 审核人负责最终判断,孩子仅在需要时补充材料。
+- **完成阶段**: 任务主体冻结,仅保留查看、统计和补审能力。
+
+### 3.10 接口级字段清单
+
+> 说明:本节按接口维度列出任务创建、任务完成与任务审核所需字段,并补充请求示例与返回语义。该清单用于前后端联调、接口文档和参数校验。
+
+#### 3.10.1 创建任务接口
+
+**接口**:`POST /api/tasks`
+ 
+| 字段 | 是否必填 | 说明 | 责任主体 |
+|------|----------|------|----------|
+| title | 是 | 任务名称 | 家长/创建人 |
+| childId / childIds | 是 | 执行孩子 | 家长/创建人 |
+| points | 是 | 奖励积分 | 家长/创建人 |
+| category | 是 | 任务分类 | 家长/创建人 |
+| deadline | 是 | 截止时间 | 家长/创建人 |
+| description | 否 | 任务描述 | 家长/创建人 |
+| repeatType | 否 | 重复规则 | 家长/创建人 |
+| completeTypes | 否 | 完成方式 | 家长/创建人 |
+| needReview | 否 | 是否需要审核 | 家长/创建人 |
+| reviewType | 否 | 审核方式 | 家长/创建人 |
+| duration | 否 | 预计时长 | 家长/创建人 |
+| earliestStart | 否 | 最早开始时间 | 家长/创建人 |
+| latestEnd | 否 | 最晚结束时间 | 家长/创建人 |
+| templateId | 否 | 模板来源 | 系统/家长 |
+| bonusRule | 否 | 超额积分规则 | 家长/创建人 |
+| pointsExpireDays | 否 | 积分有效期 | 家长/创建人 |
+
+**校验要求**
+- 必填项不得为空。
+- `points` 应符合积分范围约束。
+- `deadline` 应合法且不得早于当前时间。
+- 若选择模板,系统可自动回填模板字段。
+
+#### 3.10.2 完成任务接口
+
+**接口**:`POST /api/tasks/{id}/complete`
+
+| 字段 | 是否必填 | 说明 | 责任主体 |
+|------|----------|------|----------|
+| childId | 是 | 完成任务的孩子 | 孩子 |
+| completeContent / text | 否 | 完成说明 | 孩子 |
+| completeMedia | 否 | 完成附件 | 孩子 |
+| photoUrl | 否 | 图片地址(如适用) | 孩子 |
+| audioUrl | 否 | 语音地址(如适用) | 孩子 |
+| videoUrl | 否 | 视频地址(如适用) | 孩子 |
+| completedAt | 否 | 完成时间 | 系统/孩子 |
+
+**校验要求**
+- `childId` 应与任务执行对象一致。
+- 提交内容应符合任务配置的完成方式。
+- 系统应记录提交时间、提交内容和附件信息,并据规则判断是否进入待审核。
+
+#### 3.10.3 审核任务接口
+
+**接口**:`POST /api/tasks/{id}/review`
+
+| 字段 | 是否必填 | 说明 | 责任主体 |
+|------|----------|------|----------|
+| reviewResult | 是 | 审核结果(通过/驳回/退回补充) | 审核人 |
+| reviewerId | 是 | 审核人 | 审核人 |
+| reviewComment | 否 | 审核意见 | 审核人 |
+| reviewTime | 否 | 审核时间 | 系统/审核人 |
+
+**校验要求**
+- 审核人应具备该任务的审核权限。
+- 任务状态应处于待审核阶段。
+- 系统应在审核完成后更新任务状态,并触发积分发放或回滚逻辑。
+
+#### 3.10.4 请求示例
+
+**创建任务示例**
+
+| 字段 | 示例值 | 说明 |
+|------|--------|------|
+| title | 整理书桌 | 任务名称 |
+| description | 把书桌上的书本、文具和杂物整理干净 | 任务描述 |
+| childId | 10001 | 执行孩子 |
+| points | 5 | 奖励积分 |
+| category | 生活类 | 任务分类 |
+| deadline | 2026-04-20T20:00:00 | 截止时间 |
+| repeatType | none | 重复规则 |
+| completeTypes | ["checkin", "image"] | 完成方式 |
+| needReview | 1 | 需要审核 |
+| reviewType | parent | 审核方式 |
+| duration | 20 | 预计时长 |
+
+**完成任务示例**
+
+| 字段 | 示例值 | 说明 |
+|------|--------|------|
+| childId | 10001 | 完成任务的孩子 |
+| completeContent | 已经整理完毕,桌面已清空 | 完成说明 |
+| completeMedia | ["https://example.com/upload/task/20260414/desk-1.jpg"] | 完成附件 |
+| completedAt | 2026-04-14T19:30:00 | 完成时间 |
+
+**审核任务示例**
+
+| 字段 | 示例值 | 说明 |
+|------|--------|------|
+| reviewerId | 9001 | 审核人 |
+| reviewResult | approved | 审核结果 |
+| reviewComment | 整理得很整齐,符合要求 | 审核意见 |
+| reviewTime | 2026-04-14T19:40:00 | 审核时间 |
+
+#### 3.10.5 返回结果说明
+
+| 场景 | 成功返回 | 失败原因 |
+|------|----------|----------|
+| 创建任务 | 任务ID、创建时间、初始状态 | 必填字段缺失、截止时间非法、积分超出范围、执行对象无效 |
+| 完成任务 | 完成结果、积分变动信息、当前状态 | 任务不存在、任务状态不允许完成、孩子不匹配、提交内容不符合要求 |
+| 审核任务 | 审核结果、审核时间、最终状态、积分处理结果 | 审核权限不足、任务未进入待审核状态、审核结果参数非法 |
+
+#### 3.10.6 状态建议
+
+| 场景 | 建议状态 | 说明 |
+|------|----------|------|
+| 创建任务成功 | 待执行 / 待发布 | 任务已创建,等待孩子执行或等待正式发布 |
+| 完成任务成功且无需审核 | 已完成 | 系统可直接结算积分并结束流程 |
+| 完成任务成功且需要审核 | 待审核 | 任务进入家长/审核人确认阶段 |
+| 审核通过 | 已完成 | 任务正式完成,积分结算生效 |
+| 审核驳回 | 已驳回 | 任务回到补充材料或重做流程 |
+| 审核退回补充 | 待提交 | 孩子补充材料后再次提交 |
+
 ---
 
 ## 四、API接口汇总
@@ -245,7 +663,7 @@ pending → cancelled (创建者删除)
 
 ---
 
-## 五、前端页面
+## 五、前端页面一览
 
 | 页面 | 路径 | 功能 |
 |------|------|------|
@@ -255,7 +673,7 @@ pending → cancelled (创建者删除)
 
 ---
 
-## 六、待完善功能
+## 六、待完善事项
 
 | 功能 | 优先级 | 说明 |
 |------|--------|------|
@@ -267,7 +685,7 @@ pending → cancelled (创建者删除)
 
 ---
 
-## 七、数据库迁移
+## 七、数据库变更
 
 | 迁移 | 字段 | 说明 |
 |------|------|------|

+ 251 - 0
zxyj-backend/src/main/java/com/zxyj/controller/wish/WishController.java

@@ -0,0 +1,251 @@
+package com.zxyj.controller.wish;
+
+import com.zxyj.common.Result;
+import com.zxyj.dto.CreateWishDTO;
+import com.zxyj.dto.WishDTO;
+import com.zxyj.service.WishService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 心愿控制器
+ */
+@Slf4j
+@Tag(name = "心愿管理", description = "心愿创建、定价、兑换、兑现等接口")
+@RestController
+@RequestMapping("/api/wishes")
+public class WishController {
+
+    @Resource
+    private WishService wishService;
+
+    // ========== 创建心愿 ==========
+
+    @Operation(summary = "创建心愿")
+    @PostMapping
+    public Result<Long> createWish(
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute("role") String role,
+            @RequestBody CreateWishDTO dto) {
+        try {
+            Long wishId;
+            if ("child".equals(role)) {
+                wishId = wishService.createByChild(userId, dto);
+            } else {
+                wishId = wishService.createByParent(userId, dto);
+            }
+            return Result.success(wishId);
+        } catch (RuntimeException e) {
+            log.error("创建心愿失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    // ========== 定价流程 ==========
+
+    @Operation(summary = "家长定价")
+    @PutMapping("/{id}/set-price")
+    public Result<Boolean> setPrice(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> body) {
+        try {
+            Integer pointsRequired = (Integer) body.get("pointsRequired");
+            if (pointsRequired == null || pointsRequired <= 0) {
+                return Result.error("请输入有效的积分值");
+            }
+            boolean success = wishService.setPrice(id, userId, pointsRequired);
+            if (!success) {
+                return Result.error("定价失败,请检查心愿状态");
+            }
+            return Result.success(true);
+        } catch (Exception e) {
+            log.error("定价失败", e);
+            return Result.error("定价失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "家长拒绝心愿")
+    @PutMapping("/{id}/reject")
+    public Result<Boolean> rejectWish(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, String> body) {
+        try {
+            String reason = body.get("reason");
+            boolean success = wishService.rejectWish(id, userId, reason);
+            if (!success) {
+                return Result.error("拒绝失败,请检查心愿状态");
+            }
+            return Result.success(true);
+        } catch (Exception e) {
+            log.error("拒绝心愿失败", e);
+            return Result.error("拒绝失败: " + e.getMessage());
+        }
+    }
+
+    // ========== 兑换流程 ==========
+
+    @Operation(summary = "孩子申请兑换")
+    @PostMapping("/{id}/exchange")
+    public Result<Boolean> requestExchange(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId) {
+        try {
+            boolean success = wishService.requestExchange(id, userId);
+            if (!success) {
+                return Result.error("申请兑换失败,请检查心愿状态");
+            }
+            return Result.success(true);
+        } catch (RuntimeException e) {
+            log.error("申请兑换失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @Operation(summary = "家长审批兑换")
+    @PutMapping("/{id}/approve-exchange")
+    public Result<Boolean> approveExchange(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId,
+            @RequestBody Map<String, Object> body) {
+        try {
+            Boolean approved = (Boolean) body.get("approved");
+            String reason = (String) body.get("reason"); // 拒绝原因
+            if (approved == null) {
+                return Result.error("请指定审批结果");
+            }
+            boolean success = wishService.approveExchange(id, userId, approved);
+            if (!success) {
+                return Result.error("审批失败,请检查心愿状态");
+            }
+            return Result.success(true);
+        } catch (RuntimeException e) {
+            log.error("审批兑换失败", e);
+            return Result.error(e.getMessage());
+        }
+    }
+
+    // ========== 兑现流程 ==========
+
+    @Operation(summary = "家长标记已购买")
+    @PutMapping("/{id}/fulfill")
+    public Result<Boolean> markFulfilled(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId) {
+        try {
+            boolean success = wishService.markFulfilled(id, userId);
+            if (!success) {
+                return Result.error("标记失败,请检查心愿状态");
+            }
+            return Result.success(true);
+        } catch (Exception e) {
+            log.error("标记已购买失败", e);
+            return Result.error("标记失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "孩子确认收货")
+    @PutMapping("/{id}/confirm")
+    public Result<Boolean> confirmReceived(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId) {
+        try {
+            boolean success = wishService.confirmReceived(id, userId);
+            if (!success) {
+                return Result.error("确认失败,请检查心愿状态");
+            }
+            return Result.success(true);
+        } catch (Exception e) {
+            log.error("确认收货失败", e);
+            return Result.error("确认失败: " + e.getMessage());
+        }
+    }
+
+    // ========== 取消心愿 ==========
+
+    @Operation(summary = "取消心愿")
+    @DeleteMapping("/{id}")
+    public Result<Boolean> cancelWish(
+            @PathVariable Long id,
+            @RequestAttribute("userId") Long userId,
+            @RequestBody(required = false) Map<String, String> body) {
+        try {
+            String reason = body != null ? body.get("reason") : null;
+            boolean success = wishService.cancelWish(id, userId, reason);
+            if (!success) {
+                return Result.error("取消失败,请检查心愿状态");
+            }
+            return Result.success(true);
+        } catch (Exception e) {
+            log.error("取消心愿失败", e);
+            return Result.error("取消失败: " + e.getMessage());
+        }
+    }
+
+    // ========== 查询接口 ==========
+
+    @Operation(summary = "获取心愿列表")
+    @GetMapping
+    public Result<List<WishDTO>> getWishes(
+            @RequestAttribute("userId") Long userId,
+            @RequestAttribute("role") String role,
+            @RequestParam(required = false) Long childId,
+            @RequestParam(required = false) Long familyId,
+            @RequestParam(required = false) String status) {
+        try {
+            List<WishDTO> wishes;
+            if ("child".equals(role)) {
+                // 孩子只能看自己的心愿
+                wishes = wishService.getChildWishes(userId);
+            } else if (childId != null) {
+                // 家长查看指定孩子的心愿
+                wishes = wishService.getChildWishes(childId);
+            } else if (familyId != null) {
+                // 家长查看家庭所有心愿
+                wishes = wishService.getFamilyWishes(familyId);
+            } else {
+                return Result.error("请指定查询条件(childId或familyId)");
+            }
+            return Result.success(wishes);
+        } catch (Exception e) {
+            log.error("获取心愿列表失败", e);
+            return Result.error("获取失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取待处理列表(家长)")
+    @GetMapping("/pending")
+    public Result<List<WishDTO>> getPendingWishes(
+            @RequestAttribute("userId") Long userId,
+            @RequestParam Long familyId) {
+        try {
+            List<WishDTO> wishes = wishService.getParentPendingWishes(familyId);
+            return Result.success(wishes);
+        } catch (Exception e) {
+            log.error("获取待处理列表失败", e);
+            return Result.error("获取失败: " + e.getMessage());
+        }
+    }
+
+    @Operation(summary = "获取心愿详情")
+    @GetMapping("/{id}")
+    public Result<WishDTO> getWishById(@PathVariable Long id) {
+        try {
+            WishDTO wish = wishService.getWishById(id);
+            if (wish == null) {
+                return Result.error("心愿不存在");
+            }
+            return Result.success(wish);
+        } catch (Exception e) {
+            log.error("获取心愿详情失败", e);
+            return Result.error("获取失败: " + e.getMessage());
+        }
+    }
+}

+ 20 - 0
zxyj-backend/src/main/java/com/zxyj/dto/CreateWishDTO.java

@@ -0,0 +1,20 @@
+package com.zxyj.dto;
+
+import lombok.Data;
+
+/**
+ * 创建心愿请求DTO
+ */
+@Data
+public class CreateWishDTO {
+    
+    private String title;          // 心愿名称(必填)
+    
+    private String description;    // 详细描述(选填)
+    
+    private String category;       // 分类(选填)
+    
+    private Long childId;          // 目标孩子ID(家长创建时必填)
+    
+    private Integer pointsRequired; // 所需积分(家长创建时必填)
+}

+ 41 - 0
zxyj-backend/src/main/java/com/zxyj/dto/WishDTO.java

@@ -0,0 +1,41 @@
+package com.zxyj.dto;
+
+import lombok.Data;
+
+/**
+ * 心愿数据传输对象
+ */
+@Data
+public class WishDTO {
+    private Long id;
+    
+    // 归属信息
+    private Long familyId;
+    private Long childId;
+    private String childName;      // 孩子昵称(关联查询)
+    private Long createdBy;
+    private String creatorType;
+    private String creatorName;    // 创建者名称
+    
+    // 心愿内容
+    private String title;
+    private String description;
+    private String category;
+    
+    // 积分设置
+    private Integer pointsRequired;
+    private Long pointsSetBy;
+    
+    // 状态
+    private String status;
+    private String statusText;     // 状态中文描述
+    
+    // 进度信息
+    private Integer currentPoints; // 当前积分
+    private Integer progress;      // 进度百分比
+    
+    // 时间信息
+    private String createdAt;
+    private String exchangeRequestedAt;
+    private String fulfilledAt;
+}

+ 66 - 0
zxyj-backend/src/main/java/com/zxyj/entity/Wish.java

@@ -0,0 +1,66 @@
+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.io.Serializable;
+import java.util.Date;
+
+/**
+ * 心愿实体类
+ * 统一管理孩子和家长创建的心愿
+ */
+@Data
+@TableName("wishes")
+public class Wish implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    // ========== 归属信息 ==========
+    private Long familyId;
+    private Long childId;           // 目标孩子ID
+    private Long createdBy;         // 创建者用户ID
+    private String creatorType;     // 创建者类型: child/parent
+
+    // ========== 心愿内容 ==========
+    private String title;
+    private String description;
+    private String category;
+
+    // ========== 积分设置 ==========
+    private Integer pointsRequired; // 所需积分
+    private Long pointsSetBy;       // 定价者ID
+    private Date pointsSetAt;       // 定价时间
+
+    // ========== 状态 ==========
+    private String status;          // draft/active/pending_exchange/approved/exchanging/fulfilled/cancelled/rejected
+
+    // ========== 兑换流程 ==========
+    private Date exchangeRequestedAt;   // 申请兑换时间
+    private Long exchangeApprovedBy;    // 批准者ID
+    private Date exchangeApprovedAt;    // 批准时间
+    private Date pointsDeductedAt;      // 扣积分时间
+
+    // ========== 兑现流程 ==========
+    private Long fulfilledBy;          // 兑现者ID
+    private Date fulfilledAt;          // 兑现时间
+    private Date childConfirmedAt;     // 孩子确认收货时间
+
+    // ========== 拒绝记录 ==========
+    private Long rejectedBy;           // 拒绝者ID
+    private Date rejectedAt;           // 拒绝时间
+    private String rejectReason;       // 拒绝原因
+    private Integer rejectCount;       // 被拒绝次数
+
+    // ========== 取消记录 ==========
+    private Long cancelledBy;          // 取消者ID
+    private Date cancelledAt;          // 取消时间
+    private String cancelReason;       // 取消原因
+
+    // ========== 时间戳 ==========
+    private Date createdAt;
+    private Date updatedAt;
+}

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

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

+ 33 - 37
zxyj-backend/src/main/java/com/zxyj/service/UserService.java

@@ -518,46 +518,40 @@ private WechatService wechatService;
         return "parent".equals(role) || "child".equals(role) || "teacher".equals(role);
     }
 
-    /**
-     * 家长切换到指定孩子身份
-     */
-    public Long switchToChild(Long userId, Long childId) {
-        User user = userMapper.selectById(userId);
-        if (user == null || !"parent".equals(user.getRole())) {
-            return null;
-        }
-        
-        Child child = childMapper.selectById(childId);
-        if (child == null || !child.getFamilyId().equals(user.getFamilyId())) {
-            return null;
-        }
-        
-        // 保存原用户ID到会话中,后续可以切回
-        // 这里用简单的机制:存储到用户的 extended 字段或者新表
-        // 为简化,先返回 childId,实际切换通过 setRole 实现
-        
-        // 实际切换角色为 child
-        user.setRole("child");
-        user.setUpdatedAt(new Date());
-        userMapper.updateById(user);
-        
-        return childId;
+/**
+   * 家长切换到指定孩子身份(视图切换,不修改用户的实际角色)
+   * 家长可以在家长模式下查看孩子的任务和积分
+   */
+  public Long switchToChild(Long userId, Long childId) {
+    User user = userMapper.selectById(userId);
+    if (user == null) {
+      return null;
     }
 
-    /**
-     * 孩子切换回家长身份
-     */
-    public boolean switchBackToParent(Long userId) {
-        User user = userMapper.selectById(userId);
-        if (user == null || !"child".equals(user.getRole())) {
-            return false;
-        }
-        user.setRole("parent");
-        user.setUpdatedAt(new Date());
-        userMapper.updateById(user);
-        return true;
+    Child child = childMapper.selectById(childId);
+    if (child == null || !child.getFamilyId().equals(user.getFamilyId())) {
+      return null;
     }
 
+    // 注意:不修改用户的 role 字段,只是返回 childId 让前端切换视图
+    // 前端应该使用 currentRole 或 viewMode 来控制显示模式
+    return childId;
+  }
+
+  /**
+   * 从孩子视图切换回家长视图
+   * 这只是确认切换操作,不需要修改数据库
+   */
+  public boolean switchBackToParent(Long userId) {
+    User user = userMapper.selectById(userId);
+    if (user == null) {
+      return false;
+    }
+    // 不需要修改用户的 role 字段
+    // 只是确认操作成功,前端切换视图
+    return true;
+  }
+
     public LoginResultDTO registerWithIdCard(RegisterWithIdCardDTO dto) {
         // 验证验证码
         boolean valid = verificationCodeService.verifyCode(dto.getPhone(), dto.getCode(), "login");
@@ -641,7 +635,9 @@ private WechatService wechatService;
         if (dto.getPenaltyEnabled() != null) {
             child.setPenaltyEnabled(dto.getPenaltyEnabled());
         } else {
-            child.setPenaltyEnabled(dto.getAge() >= 6 ? 1 : 0);
+            // 年龄可能为空,默认不扣分
+            Integer age = dto.getAge();
+            child.setPenaltyEnabled(age != null && age >= 6 ? 1 : 0);
         }
         child.setTheme("default");
         child.setTotalPoints(0);

+ 469 - 0
zxyj-backend/src/main/java/com/zxyj/service/WishService.java

@@ -0,0 +1,469 @@
+package com.zxyj.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.zxyj.dto.CreateWishDTO;
+import com.zxyj.dto.WishDTO;
+import com.zxyj.entity.Child;
+import com.zxyj.entity.PointsLog;
+import com.zxyj.entity.User;
+import com.zxyj.entity.Wish;
+import com.zxyj.mapper.ChildMapper;
+import com.zxyj.mapper.PointsLogMapper;
+import com.zxyj.mapper.UserMapper;
+import com.zxyj.mapper.WishMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 心愿服务类
+ */
+@Slf4j
+@Service
+public class WishService {
+
+    @Resource
+    private WishMapper wishMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
+    @Resource
+    private PointsLogMapper pointsLogMapper;
+
+    // ========== 创建心愿 ==========
+
+    /**
+     * 孩子创建心愿(状态:draft)
+     */
+    @Transactional
+    public Long createByChild(Long childId, CreateWishDTO dto) {
+        Child child = childMapper.selectById(childId);
+        if (child == null) {
+            throw new RuntimeException("孩子不存在");
+        }
+
+        Wish wish = new Wish();
+        wish.setFamilyId(child.getFamilyId());
+        wish.setChildId(childId);
+        wish.setCreatedBy(child.getUserId()); // 创建者为孩子关联的用户
+        wish.setCreatorType("child");
+        wish.setTitle(dto.getTitle());
+        wish.setDescription(dto.getDescription());
+        wish.setCategory(dto.getCategory());
+        wish.setStatus("draft");
+        wish.setRejectCount(0);
+        wish.setCreatedAt(new Date());
+        wish.setUpdatedAt(new Date());
+        wishMapper.insert(wish);
+
+        log.info("孩子{}创建心愿: {}", childId, dto.getTitle());
+        return wish.getId();
+    }
+
+    /**
+     * 家长创建心愿(状态:active,已定价)
+     */
+    @Transactional
+    public Long createByParent(Long parentId, CreateWishDTO dto) {
+        User parent = userMapper.selectById(parentId);
+        if (parent == null) {
+            throw new RuntimeException("用户不存在");
+        }
+        if (dto.getChildId() == null) {
+            throw new RuntimeException("请选择目标孩子");
+        }
+        if (dto.getPointsRequired() == null || dto.getPointsRequired() <= 0) {
+            throw new RuntimeException("请设置所需积分");
+        }
+
+        Child child = childMapper.selectById(dto.getChildId());
+        if (child == null || !child.getFamilyId().equals(parent.getFamilyId())) {
+            throw new RuntimeException("孩子不存在或不属于该家庭");
+        }
+
+        Wish wish = new Wish();
+        wish.setFamilyId(parent.getFamilyId());
+        wish.setChildId(dto.getChildId());
+        wish.setCreatedBy(parentId);
+        wish.setCreatorType("parent");
+        wish.setTitle(dto.getTitle());
+        wish.setDescription(dto.getDescription());
+        wish.setCategory(dto.getCategory());
+        wish.setPointsRequired(dto.getPointsRequired());
+        wish.setPointsSetBy(parentId);
+        wish.setPointsSetAt(new Date());
+        wish.setStatus("active");
+        wish.setRejectCount(0);
+        wish.setCreatedAt(new Date());
+        wish.setUpdatedAt(new Date());
+        wishMapper.insert(wish);
+
+        log.info("家长{}创建心愿: {}, 目标孩子: {}", parentId, dto.getTitle(), dto.getChildId());
+        return wish.getId();
+    }
+
+    // ========== 定价流程 ==========
+
+    /**
+     * 家长定价(draft -> active)
+     */
+    @Transactional
+    public boolean setPrice(Long wishId, Long parentId, Integer pointsRequired) {
+        Wish wish = wishMapper.selectById(wishId);
+        if (wish == null) {
+            log.warn("心愿不存在: {}", wishId);
+            return false;
+        }
+        if (!"draft".equals(wish.getStatus())) {
+            log.warn("心愿状态不是draft: {}", wish.getStatus());
+            return false;
+        }
+
+        // 验证家长权限
+        User parent = userMapper.selectById(parentId);
+        if (parent == null || !parent.getFamilyId().equals(wish.getFamilyId())) {
+            log.warn("家长{}无权操作心愿{}", parentId, wishId);
+            return false;
+        }
+
+        wish.setPointsRequired(pointsRequired);
+        wish.setPointsSetBy(parentId);
+        wish.setPointsSetAt(new Date());
+        wish.setStatus("active");
+        wish.setUpdatedAt(new Date());
+        wishMapper.updateById(wish);
+
+        log.info("家长{}为心愿{}定价: {}积分", parentId, wishId, pointsRequired);
+        return true;
+    }
+
+    /**
+     * 家长拒绝心愿(draft -> rejected)
+     */
+    @Transactional
+    public boolean rejectWish(Long wishId, Long parentId, String reason) {
+        Wish wish = wishMapper.selectById(wishId);
+        if (wish == null || !"draft".equals(wish.getStatus())) {
+            return false;
+        }
+
+        User parent = userMapper.selectById(parentId);
+        if (parent == null || !parent.getFamilyId().equals(wish.getFamilyId())) {
+            return false;
+        }
+
+        wish.setStatus("rejected");
+        wish.setRejectedBy(parentId);
+        wish.setRejectedAt(new Date());
+        wish.setRejectReason(reason);
+        wish.setRejectCount(wish.getRejectCount() + 1);
+        wish.setUpdatedAt(new Date());
+        wishMapper.updateById(wish);
+
+        log.info("家长{}拒绝心愿{}, 原因: {}", parentId, wishId, reason);
+        return true;
+    }
+
+    // ========== 兑换流程 ==========
+
+    /**
+     * 孩子申请兑换(active -> pending_exchange)
+     */
+    @Transactional
+    public boolean requestExchange(Long wishId, Long childId) {
+        Wish wish = wishMapper.selectById(wishId);
+        if (wish == null) {
+            log.warn("心愿不存在: {}", wishId);
+            return false;
+        }
+        if (!"active".equals(wish.getStatus())) {
+            log.warn("心愿状态不是active: {}", wish.getStatus());
+            return false;
+        }
+        if (!wish.getChildId().equals(childId)) {
+            log.warn("心愿{}不属于孩子{}", wishId, childId);
+            return false;
+        }
+
+        // 检查积分是否充足
+        Child child = childMapper.selectById(childId);
+        if (child.getTotalPoints() < wish.getPointsRequired()) {
+            throw new RuntimeException("积分不足,无法申请兑换");
+        }
+
+        // 检查是否有其他进行中的兑换申请
+        Long count = wishMapper.selectCount(new LambdaQueryWrapper<Wish>()
+                .eq(Wish::getChildId, childId)
+                .eq(Wish::getStatus, "pending_exchange"));
+        if (count > 0) {
+            throw new RuntimeException("您有一个兑换申请正在审批中,请等待审批完成");
+        }
+
+        wish.setStatus("pending_exchange");
+        wish.setExchangeRequestedAt(new Date());
+        wish.setUpdatedAt(new Date());
+        wishMapper.updateById(wish);
+
+        log.info("孩子{}申请兑换心愿{}", childId, wishId);
+        return true;
+    }
+
+    /**
+     * 家长批准兑换(pending_exchange -> approved,扣减积分)
+     */
+    @Transactional
+    public boolean approveExchange(Long wishId, Long parentId, boolean approved) {
+        Wish wish = wishMapper.selectById(wishId);
+        if (wish == null || !"pending_exchange".equals(wish.getStatus())) {
+            return false;
+        }
+
+        User parent = userMapper.selectById(parentId);
+        if (parent == null || !parent.getFamilyId().equals(wish.getFamilyId())) {
+            return false;
+        }
+
+        if (approved) {
+            // 再次检查积分是否充足(防止并发问题)
+            Child child = childMapper.selectById(wish.getChildId());
+            if (child.getTotalPoints() < wish.getPointsRequired()) {
+                throw new RuntimeException("积分不足,无法兑换");
+            }
+
+            // 扣减积分
+            child.setTotalPoints(child.getTotalPoints() - wish.getPointsRequired());
+            child.setUpdatedAt(new Date());
+            childMapper.updateById(child);
+
+            // 记录积分消耗
+            PointsLog pointsLog = new PointsLog();
+            pointsLog.setChildId(wish.getChildId());
+            pointsLog.setAmount(-wish.getPointsRequired());
+            pointsLog.setType("wish_exchange");
+            pointsLog.setDescription("兑换心愿: " + wish.getTitle());
+            pointsLog.setCreatedAt(new Date());
+            pointsLogMapper.insert(pointsLog);
+
+            wish.setStatus("approved");
+            wish.setExchangeApprovedBy(parentId);
+            wish.setExchangeApprovedAt(new Date());
+            wish.setPointsDeductedAt(new Date());
+            log.info("家长{}批准兑换心愿{}, 扣除{}积分", parentId, wishId, wish.getPointsRequired());
+        } else {
+            // 拒绝,回到 active 状态
+            wish.setStatus("active");
+            wish.setRejectedBy(parentId);
+            wish.setRejectedAt(new Date());
+            wish.setRejectCount(wish.getRejectCount() + 1);
+            log.info("家长{}拒绝兑换心愿{}", parentId, wishId);
+        }
+
+        wish.setUpdatedAt(new Date());
+        wishMapper.updateById(wish);
+        return true;
+    }
+
+    // ========== 兑现流程 ==========
+
+    /**
+     * 家长标记已购买(approved -> exchanging)
+     */
+    @Transactional
+    public boolean markFulfilled(Long wishId, Long parentId) {
+        Wish wish = wishMapper.selectById(wishId);
+        if (wish == null || !"approved".equals(wish.getStatus())) {
+            return false;
+        }
+
+        User parent = userMapper.selectById(parentId);
+        if (parent == null || !parent.getFamilyId().equals(wish.getFamilyId())) {
+            return false;
+        }
+
+        wish.setStatus("exchanging");
+        wish.setFulfilledBy(parentId);
+        wish.setFulfilledAt(new Date());
+        wish.setUpdatedAt(new Date());
+        wishMapper.updateById(wish);
+
+        log.info("家长{}标记心愿{}已购买", parentId, wishId);
+        return true;
+    }
+
+    /**
+     * 孩子确认收货(exchanging -> fulfilled)
+     */
+    @Transactional
+    public boolean confirmReceived(Long wishId, Long childId) {
+        Wish wish = wishMapper.selectById(wishId);
+        if (wish == null || !"exchanging".equals(wish.getStatus())) {
+            return false;
+        }
+        if (!wish.getChildId().equals(childId)) {
+            return false;
+        }
+
+        wish.setStatus("fulfilled");
+        wish.setChildConfirmedAt(new Date());
+        wish.setUpdatedAt(new Date());
+        wishMapper.updateById(wish);
+
+        log.info("孩子{}确认收货心愿{}", childId, wishId);
+        return true;
+    }
+
+    // ========== 取消心愿 ==========
+
+    /**
+     * 取消心愿
+     */
+    @Transactional
+    public boolean cancelWish(Long wishId, Long userId, String reason) {
+        Wish wish = wishMapper.selectById(wishId);
+        if (wish == null) {
+            return false;
+        }
+
+        // 只有 draft 或 active 状态可以取消
+        if (!"draft".equals(wish.getStatus()) && !"active".equals(wish.getStatus())) {
+            return false;
+        }
+
+        wish.setStatus("cancelled");
+        wish.setCancelledBy(userId);
+        wish.setCancelledAt(new Date());
+        wish.setCancelReason(reason);
+        wish.setUpdatedAt(new Date());
+        wishMapper.updateById(wish);
+
+        log.info("用户{}取消心愿{}, 原因: {}", userId, wishId, reason);
+        return true;
+    }
+
+    // ========== 查询接口 ==========
+
+    /**
+     * 获取孩子的心愿列表(孩子视角)
+     */
+    public List<WishDTO> getChildWishes(Long childId) {
+        List<Wish> wishes = wishMapper.selectList(new LambdaQueryWrapper<Wish>()
+                .eq(Wish::getChildId, childId)
+                .notIn(Wish::getStatus, Arrays.asList("cancelled"))
+                .orderByDesc(Wish::getCreatedAt));
+
+        Child child = childMapper.selectById(childId);
+        int currentPoints = child != null ? child.getTotalPoints() : 0;
+
+        return wishes.stream().map(w -> toDTO(w, currentPoints)).collect(Collectors.toList());
+    }
+
+    /**
+     * 获取家庭所有心愿(家长视角)
+     */
+    public List<WishDTO> getFamilyWishes(Long familyId) {
+        List<Wish> wishes = wishMapper.selectList(new LambdaQueryWrapper<Wish>()
+                .eq(Wish::getFamilyId, familyId)
+                .orderByDesc(Wish::getCreatedAt));
+
+        return wishes.stream().map(w -> toDTO(w, null)).collect(Collectors.toList());
+    }
+
+    /**
+     * 获取家长待处理列表
+     */
+    public List<WishDTO> getParentPendingWishes(Long familyId) {
+        List<Wish> wishes = wishMapper.selectList(new LambdaQueryWrapper<Wish>()
+                .eq(Wish::getFamilyId, familyId)
+                .and(w -> w
+                        .eq(Wish::getStatus, "draft")          // 待定价
+                        .or().eq(Wish::getStatus, "pending_exchange")  // 待审批兑换
+                        .or().eq(Wish::getStatus, "approved")  // 待兑现
+                )
+                .orderByDesc(Wish::getCreatedAt));
+
+        return wishes.stream().map(w -> toDTO(w, null)).collect(Collectors.toList());
+    }
+
+    /**
+     * 获取心愿详情
+     */
+    public WishDTO getWishById(Long wishId) {
+        Wish wish = wishMapper.selectById(wishId);
+        if (wish == null) {
+            return null;
+        }
+        return toDTO(wish, null);
+    }
+
+    // ========== 辅助方法 ==========
+
+    private WishDTO toDTO(Wish wish, Integer currentPoints) {
+        WishDTO dto = new WishDTO();
+        dto.setId(wish.getId());
+        dto.setFamilyId(wish.getFamilyId());
+        dto.setChildId(wish.getChildId());
+        dto.setCreatedBy(wish.getCreatedBy());
+        dto.setCreatorType(wish.getCreatorType());
+        dto.setTitle(wish.getTitle());
+        dto.setDescription(wish.getDescription());
+        dto.setCategory(wish.getCategory());
+        dto.setPointsRequired(wish.getPointsRequired());
+        dto.setPointsSetBy(wish.getPointsSetBy());
+        dto.setStatus(wish.getStatus());
+        dto.setStatusText(getStatusText(wish.getStatus()));
+
+        // 计算进度
+        if (currentPoints != null && wish.getPointsRequired() != null && wish.getPointsRequired() > 0) {
+            dto.setCurrentPoints(currentPoints);
+            dto.setProgress(Math.min(100, (currentPoints * 100) / wish.getPointsRequired()));
+        }
+
+        // 格式化时间
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+        if (wish.getCreatedAt() != null) {
+            dto.setCreatedAt(sdf.format(wish.getCreatedAt()));
+        }
+        if (wish.getExchangeRequestedAt() != null) {
+            dto.setExchangeRequestedAt(sdf.format(wish.getExchangeRequestedAt()));
+        }
+        if (wish.getFulfilledAt() != null) {
+            dto.setFulfilledAt(sdf.format(wish.getFulfilledAt()));
+        }
+
+        // 获取孩子昵称
+        if (wish.getChildId() != null) {
+            Child child = childMapper.selectById(wish.getChildId());
+            if (child != null) {
+                dto.setChildName(child.getNickname());
+            }
+        }
+
+        return dto;
+    }
+
+    private String getStatusText(String status) {
+        if (status == null) return "";
+        switch (status) {
+            case "draft": return "待定价";
+            case "active": return "积攒中";
+            case "pending_exchange": return "待审批";
+            case "approved": return "已批准";
+            case "exchanging": return "待收货";
+            case "fulfilled": return "已完成";
+            case "cancelled": return "已取消";
+            case "rejected": return "已拒绝";
+            default: return status;
+        }
+    }
+}

+ 144 - 0
zxyj-backend/src/main/resources/migration_wishes.sql

@@ -0,0 +1,144 @@
+-- 心愿功能重构迁移脚本
+-- 执行时间: 预计 5 分钟
+-- 影响范围: rewards, reward_wishlists 表数据
+-- 执行前请备份数据!
+
+-- 1. 创建新表 wishes
+CREATE TABLE IF NOT EXISTS wishes (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    
+    -- 归属信息
+    family_id BIGINT NOT NULL COMMENT '家庭ID',
+    child_id BIGINT NOT NULL COMMENT '目标孩子ID',
+    created_by BIGINT NOT NULL COMMENT '创建者用户ID',
+    creator_type VARCHAR(10) NOT NULL COMMENT '创建者类型: child/parent',
+    
+    -- 心愿内容
+    title VARCHAR(200) NOT NULL COMMENT '心愿名称',
+    description TEXT COMMENT '详细描述',
+    category VARCHAR(50) COMMENT '分类',
+    
+    -- 积分设置
+    points_required INT COMMENT '所需积分',
+    points_set_by BIGINT COMMENT '定价者ID',
+    points_set_at DATETIME COMMENT '定价时间',
+    
+    -- 状态
+    status VARCHAR(20) DEFAULT 'draft' COMMENT '状态: draft/active/pending_exchange/approved/exchanging/fulfilled/cancelled/rejected',
+    
+    -- 兑换流程
+    exchange_requested_at DATETIME COMMENT '申请兑换时间',
+    exchange_approved_by BIGINT COMMENT '批准者ID',
+    exchange_approved_at DATETIME COMMENT '批准时间',
+    points_deducted_at DATETIME COMMENT '扣积分时间',
+    
+    -- 兑现流程
+    fulfilled_by BIGINT COMMENT '兑现者ID',
+    fulfilled_at DATETIME COMMENT '兑现时间',
+    child_confirmed_at DATETIME COMMENT '孩子确认收货时间',
+    
+    -- 拒绝记录
+    rejected_by BIGINT COMMENT '拒绝者ID',
+    rejected_at DATETIME COMMENT '拒绝时间',
+    reject_reason VARCHAR(500) COMMENT '拒绝原因',
+    reject_count INT DEFAULT 0 COMMENT '被拒绝次数',
+    
+    -- 取消记录
+    cancelled_by BIGINT COMMENT '取消者ID',
+    cancelled_at DATETIME COMMENT '取消时间',
+    cancel_reason VARCHAR(500) COMMENT '取消原因',
+    
+    -- 时间戳
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    
+    -- 索引
+    INDEX idx_family (family_id),
+    INDEX idx_child (child_id),
+    INDEX idx_status (status),
+    INDEX idx_creator (created_by)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='心愿表';
+
+-- 2. 迁移 rewards 表数据(孩子创建的心愿)
+-- 注意:这里需要根据实际数据调整
+INSERT INTO wishes (
+    id, family_id, child_id, created_by, creator_type,
+    title, description, category, points_required, points_set_by,
+    status, created_at, updated_at
+)
+SELECT 
+    r.id,
+    r.family_id,
+    r.child_id,
+    c.user_id as created_by,  -- 创建者为孩子关联的用户
+    'child' as creator_type,
+    r.title,
+    NULL as description,
+    r.category,
+    CASE WHEN r.status = 'pending_approval' THEN NULL ELSE r.points_required END,
+    CASE WHEN r.status = 'pending_approval' THEN NULL ELSE 1 END,  -- 需要后续修正
+    CASE 
+        WHEN r.status = 'pending_approval' THEN 'draft'
+        WHEN r.status = 'available' THEN 'active'
+        WHEN r.status = 'pending' THEN 'approved'
+        WHEN r.status = 'exchanged' THEN 'fulfilled'
+        WHEN r.status = 'rejected' THEN 'rejected'
+        ELSE r.status
+    END,
+    r.created_at,
+    r.updated_at
+FROM rewards r
+LEFT JOIN children c ON r.child_id = c.id
+WHERE r.is_template = 0 OR r.is_template IS NULL;
+
+-- 3. 迁移 reward_wishlists 表数据(家长创建的心愿)
+-- 注意:家长创建的心愿需要指定目标孩子,这里暂时设为0,需要后续修正
+INSERT INTO wishes (
+    family_id, child_id, created_by, creator_type,
+    title, description, category,
+    points_required, points_set_by, points_set_at,
+    status, created_at, updated_at
+)
+SELECT 
+    rw.family_id,
+    0 as child_id,  -- 需要后续修正
+    rw.user_id as created_by,
+    'parent' as creator_type,
+    rw.title,
+    rw.description,
+    rw.category,
+    rw.points_required,
+    rw.user_id as points_set_by,
+    rw.created_at as points_set_at,
+    CASE 
+        WHEN rw.status = 'pending' THEN 'active'
+        WHEN rw.status = 'exchanged' THEN 'fulfilled'
+        WHEN rw.status = 'rejected' THEN 'rejected'
+        ELSE rw.status
+    END,
+    rw.created_at,
+    rw.updated_at
+FROM reward_wishlists rw;
+
+-- 4. 修正 points_set_by 字段(根据家庭关系更新)
+-- 找到同一家庭的家长作为定价者
+UPDATE wishes w
+JOIN children c ON w.child_id = c.id
+JOIN users u ON c.family_id = u.family_id AND u.role = 'parent'
+SET w.points_set_by = u.id
+WHERE w.creator_type = 'child' 
+  AND w.points_set_by IS NULL 
+  AND w.points_required IS NOT NULL
+  AND w.status != 'draft';
+
+-- 5. 备份旧表(不删除,保留备份)
+-- 如果需要可以手动执行以下语句:
+-- RENAME TABLE rewards TO rewards_backup_20260414;
+-- RENAME TABLE reward_wishlists TO reward_wishlists_backup_20260414;
+
+-- 6. 验证迁移结果
+SELECT 'wishes表记录数' as info, COUNT(*) as count FROM wishes
+UNION ALL
+SELECT 'rewards表记录数', COUNT(*) FROM rewards
+UNION ALL
+SELECT 'reward_wishlists表记录数', COUNT(*) FROM reward_wishlists;

+ 55 - 25
zxyj-frontend/pages.json

@@ -13,24 +13,36 @@
         "navigationBarTitleText": "完善信息"
       }
     },
-  {
-    "path": "pages/index/index",
-    "style": {
-      "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/child/tasks",
+      "style": {
+        "navigationBarTitleText": "孩子任务"
+      }
+    },
+    {
+      "path": "pages/child/rewards",
+      "style": {
+        "navigationBarTitleText": "孩子心愿单"
+      }
+    },
+    {
+      "path": "pages/index/parent-index",
+      "style": {
+        "navigationBarTitleText": "首页"
+      }
+    },
     {
       "path": "pages/tasks/tasks",
       "style": {
@@ -163,13 +175,31 @@
         "navigationBarTitleText": "猜数字"
       }
     },
-    {
-      "path": "pages/games/sudoku",
-      "style": {
-        "navigationBarTitleText": "数独"
-      }
-    }
-  ],
+{
+"path": "pages/games/sudoku",
+"style": {
+"navigationBarTitleText": "数独"
+}
+},
+{
+"path": "pages/wishes/index",
+"style": {
+"navigationBarTitleText": "我的心愿"
+}
+},
+{
+"path": "pages/wishes/create",
+"style": {
+"navigationBarTitleText": "添加心愿"
+}
+},
+{
+"path": "pages/wishes/detail",
+"style": {
+"navigationBarTitleText": "心愿详情"
+}
+}
+],
   "globalStyle": {
     "navigationBarTextStyle": "black",
     "navigationBarTitleText": "知行益家",

+ 113 - 0
zxyj-frontend/pages/child/rewards.vue

@@ -0,0 +1,113 @@
+<template>
+  <view class="container">
+    <view class="reward-list" v-if="rewards.length > 0">
+      <view v-for="reward in rewards" :key="reward.id" class="reward-item">
+        <view class="reward-info">
+          <view class="reward-title">{{ reward.title }}</view>
+          <view class="reward-progress">
+            <progress :percent="reward.pointsRequired > 0 ? (currentPoints / reward.pointsRequired) * 100 : 0" stroke-width="6" activeColor="#FF6B6B" backgroundColor="#eee" />
+            <text class="progress-text">{{ currentPoints }}/{{ reward.pointsRequired }}积分</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="empty" v-else>
+      <text>暂无心愿单,快去添加吧</text>
+    </view>
+
+    <button class="btn-primary add-btn" @click="showAddModal = true">+ 添加心愿</button>
+
+    <view class="modal-mask" v-if="showAddModal" @click="showAddModal = false">
+      <view class="modal" @click.stop>
+        <view class="modal-title">添加心愿</view>
+        <view class="form-item">
+          <view class="form-label">心愿名称 <text class="required">*</text></view>
+          <input v-model="newReward.title" placeholder="请输入心愿名称" />
+        </view>
+        <view class="form-tip">
+          <text>孩子创建心愿后,需要家长填写所需积分才能生效</text>
+        </view>
+        <view class="modal-btns">
+          <button @click="showAddModal = false">取消</button>
+          <button class="btn-primary" @click="createChildReward">确定</button>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { getWishlist, createReward as createRewardApi, getPointsBalance, getChildren } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      rewards: [],
+      currentPoints: 0,
+      currentChildId: '',
+      showAddModal: false,
+      newReward: { title: '' }
+    }
+  },
+  onShow() {
+    this.loadRewards()
+  },
+  methods: {
+    async loadRewards() {
+      try {
+        const childrenRes = await getChildren()
+        const children = childrenRes.data || []
+        const storedChildId = uni.getStorageSync('currentChildId')
+        const currentChild = children.find(c => c.id == storedChildId) || children[0]
+        if (!currentChild) {
+          this.rewards = []
+          this.currentPoints = 0
+          return
+        }
+        this.currentChildId = currentChild.id
+        const balanceRes = await getPointsBalance(currentChild.id)
+        this.currentPoints = balanceRes.data.balance
+        const res = await getWishlist(currentChild.id)
+        this.rewards = res.data || []
+      } catch (e) {
+        console.error('加载心愿单失败', e)
+      }
+    },
+    async createChildReward() {
+      if (!this.newReward.title) {
+        uni.showToast({ title: '请填写心愿名称', icon: 'none' })
+        return
+      }
+      try {
+        await createRewardApi({ title: this.newReward.title, childId: this.currentChildId })
+        uni.showToast({ title: '已提交,等待家长填写积分', icon: 'success' })
+        this.showAddModal = false
+        this.newReward.title = ''
+        this.loadRewards()
+      } catch (e) {
+        console.error('添加心愿失败', e)
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 30rpx; padding-bottom: 120rpx; }
+.reward-item { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; }
+.reward-title { font-size: 30rpx; color: #333; margin-bottom: 15rpx; }
+.reward-progress { width: 100%; }
+.progress-text { font-size: 22rpx; color: #999; display: block; margin-top: 8rpx; }
+.add-btn { position: fixed; bottom: 30rpx; left: 30rpx; right: 30rpx; }
+.empty { text-align: center; color: #999; padding: 100rpx; }
+.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; 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-label { font-size: 28rpx; color: #333; margin-bottom: 10rpx; }
+.required { color: #FF6B6B; }
+.form-tip { font-size: 24rpx; color: #999; padding: 20rpx; background: #f5f5f5; border-radius: 10rpx; margin-bottom: 20rpx; }
+.modal-btns { display: flex; gap: 20rpx; margin-top: 30rpx; }
+.modal-btns button { flex: 1; }
+</style>

+ 90 - 0
zxyj-frontend/pages/child/tasks.vue

@@ -0,0 +1,90 @@
+<template>
+  <PlayfulCard elevation="1">
+    <view class="container">
+      <view class="task-list" v-if="tasks.length > 0">
+        <view v-for="task in tasks" :key="task.id" class="task-item" :class="{ completed: task.status === 'completed' }">
+          <view class="task-left">
+            <view class="task-title">{{ task.title }}</view>
+            <view class="task-meta">
+              <text class="points">+{{ task.points }}分</text>
+              <text class="deadline">截止 {{ formatTime(task.deadline) }}</text>
+            </view>
+          </view>
+          <view class="task-right">
+            <button v-if="task.status === 'pending'" class="btn-complete" @click="completeChildTask(task.id)">完成</button>
+            <view v-else class="completed-badge">已完成</view>
+          </view>
+        </view>
+      </view>
+
+      <view class="empty" v-else>
+        <text>暂无今日任务</text>
+      </view>
+    </view>
+  </PlayfulCard>
+</template>
+
+<script>
+import PlayfulCard from '@/components/PlayfulCard.vue'
+import { getTodayTasks, completeTask as completeTaskApi, getChildren } from '../../utils/api.js'
+
+export default {
+  components: { PlayfulCard },
+  data() {
+    return {
+      tasks: [],
+      currentChildId: ''
+    }
+  },
+  onShow() {
+    this.loadData()
+  },
+  methods: {
+    async loadData() {
+      try {
+        const childrenRes = await getChildren()
+        const children = childrenRes.data || []
+        const storedChildId = uni.getStorageSync('currentChildId')
+        const currentChild = children.find(c => c.id == storedChildId) || children[0]
+        if (!currentChild) {
+          this.tasks = []
+          this.currentChildId = ''
+          return
+        }
+        this.currentChildId = currentChild.id
+        const res = await getTodayTasks(currentChild.id)
+        this.tasks = res.data || []
+      } catch (e) {
+        console.error('加载孩子任务失败', e)
+      }
+    },
+    async completeChildTask(taskId) {
+      try {
+        const res = await completeTaskApi(taskId, this.currentChildId, '')
+        uni.showToast({ title: `获得 ${res.data.pointsEarned} 积分`, icon: 'success' })
+        this.loadData()
+      } catch (e) {
+        console.error('完成任务失败', e)
+      }
+    },
+    formatTime(dateStr) {
+      if (!dateStr) return ''
+      const date = new Date(dateStr)
+      return `${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container { padding: 30rpx; }
+.task-item { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-bottom: 20rpx; display: flex; justify-content: space-between; align-items: center; }
+.task-item.completed { opacity: 0.6; }
+.task-title { font-size: 30rpx; color: #333; margin-bottom: 10rpx; }
+.task-meta { display: flex; gap: 20rpx; }
+.points { color: #FF6B6B; font-size: 24rpx; }
+.deadline { color: #999; font-size: 24rpx; }
+.btn-complete { background: #FF6B6B; color: #fff; font-size: 26rpx; padding: 10rpx 30rpx; border-radius: 30rpx; }
+.completed-badge { color: #52C41A; font-size: 26rpx; }
+.empty { text-align: center; color: #999; padding: 100rpx; }
+</style>

+ 22 - 14
zxyj-frontend/pages/index/child-index.vue

@@ -164,6 +164,11 @@ export default {
   },
   onLoad() { this.checkLogin() },
   onShow() {
+    const currentRole = uni.getStorageSync('currentRole') || 'parent'
+    if (currentRole !== 'child') {
+      uni.reLaunch({ url: '/pages/index/parent-index' })
+      return
+    }
     if (uni.getStorageSync('token')) this.loadData()
   },
   methods: {
@@ -252,12 +257,12 @@ export default {
       const date = new Date(dateStr)
       return `${date.getMonth() + 1}月${date.getDate()}日`
     },
-    goToTasks() { 
-      // 从孩子端进入任务页,标记返回页面
-      uni.setStorageSync('lastVisitPage', '/pages/index/child-index')
-      uni.switchTab({ url: '/pages/tasks/tasks' })
+    goToTasks() {
+      uni.reLaunch({ url: '/pages/child/tasks' })
+    },
+    goToRewards() {
+      uni.reLaunch({ url: '/pages/child/rewards' })
     },
-    goToRewards() { uni.switchTab({ url: '/pages/rewards/rewards' }) },
     goToFocus() { uni.navigateTo({ url: '/pages/focus/focus' }) },
     showPointsHistory() { uni.showToast({ title: '积分明细功能开发中', icon: 'none' }) },
     showAchievements() { uni.showToast({ title: '成就功能开发中', icon: 'none' }) },
@@ -305,15 +310,18 @@ export default {
         }
       })
     },
-    async switchBackToParentMode() {
-      try {
-        const result = await switchBackToParent()
-        if (result.data) {
-          uni.showToast({ title: '已切换回家长模式', icon: 'success' })
-          uni.reLaunch({ url: '/pages/index/parent-index' })
-        }
-      } catch (e) { uni.showToast({ title: '切换失败', icon: 'none' }) }
-    },
+async switchBackToParentMode() {
+    try {
+      const result = await switchBackToParent()
+      if (result.data) {
+        // 切换回家长视图模式
+        uni.setStorageSync('currentRole', 'parent')
+        uni.removeStorageSync('currentChildId')
+        uni.showToast({ title: '已切换回家长模式', icon: 'success' })
+        uni.reLaunch({ url: '/pages/index/parent-index' })
+      }
+    } catch (e) { uni.showToast({ title: '切换失败', icon: 'none' }) }
+  },
     triggerConfetti() {
       this.showConfetti = true
       setTimeout(() => { this.showConfetti = false }, 3000)

+ 20 - 16
zxyj-frontend/pages/index/index.vue

@@ -1,19 +1,19 @@
 <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>
+  <!-- 家长端首页 -->
+  <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>
@@ -33,8 +33,12 @@ export default {
     }
   },
   onLoad() {
-    // 从本地存储或 store 获取角色
-    this.currentRole = uni.getStorageSync('role') || 'parent'
+    // 优先使用 currentRole(视图模式),其次使用 role(实际角色)
+    this.currentRole = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
+  },
+  onShow() {
+    // 每次显示页面时刷新角色状态
+    this.currentRole = uni.getStorageSync('currentRole') || uni.getStorageSync('role') || 'parent'
   },
   methods: {}
 }

+ 57 - 63
zxyj-frontend/pages/index/parent-index.vue

@@ -98,25 +98,12 @@ export default {
     this.checkLogin()
   },
   onShow() {
-    // 检查是否应该显示孩子首页
-    const role = uni.getStorageSync('role')
-    const lastVisitPage = uni.getStorageSync('lastVisitPage')
-    const currentRole = uni.getStorageSync('currentRole')
-    
-    // 只有从孩子端进入任务页后返回,才跳转到孩子首页
-    // 登录后首次进入首页不应触发
-    if (role === 'child' && lastVisitPage === '/pages/index/child-index') {
-      // 清除标记并跳转到孩子首页
-      uni.removeStorageSync('lastVisitPage')
+    const currentRole = uni.getStorageSync('currentRole') || 'parent'
+    if (currentRole === 'child') {
       uni.reLaunch({ url: '/pages/index/child-index' })
       return
     }
-    
-    // 确保角色是家长
-    if (role !== 'parent') {
-      uni.setStorageSync('role', 'parent')
-    }
-    
+
     if (uni.getStorageSync('token')) {
       this.loadData()
     }
@@ -186,61 +173,68 @@ export default {
     selectChild(child) {
       this.selectMember({ ...child, role: 'child' })
     },
-    // 切换到孩子身份
-    async switchToChildMode(child) {
-      uni.showModal({
-        title: '切换到孩子模式',
-        content: `确定要切换到 ${child.nickname} 的模式吗?`,
-        success: async (res) => {
-          if (res.confirm) {
-            try {
-              const result = await switchToChild(child.id)
-              if (result.data) {
-                this.currentChildId = child.id
-                this.$store.commit('switchRole', 'child')
-                this.$store.commit('setCurrentChild', child.id)
-                uni.showToast({
-                  title: `已切换到 ${child.nickname} 模式`,
-                  icon: 'success'
-                })
-                // 跳转到孩子首页
-                uni.reLaunch({
-                  url: '/pages/index/child-index'
-                })
-              }
-            } catch (e) {
+// 切换到孩子身份(仅切换视图模式,不修改数据库)
+  async switchToChildMode(child) {
+    uni.showModal({
+      title: '切换到孩子模式',
+      content: `确定要切换到 ${child.nickname} 的模式吗?`,
+      success: async (res) => {
+        if (res.confirm) {
+          try {
+            const result = await switchToChild(child.id)
+            if (result.data) {
+              this.currentChildId = child.id
+              // 只切换视图模式,不修改实际角色
+              this.$store.commit('switchViewMode', 'child')
+              this.$store.commit('setCurrentChild', child.id)
+              // 保存当前孩子ID
+              uni.setStorageSync('currentChildId', child.id)
+              uni.setStorageSync('currentRole', 'child')
               uni.showToast({
-                title: '切换失败',
-                icon: 'none'
+                title: `已切换到 ${child.nickname} 模式`,
+                icon: 'success'
+              })
+              // 跳转到孩子首页
+              uni.reLaunch({
+                url: '/pages/index/child-index'
               })
             }
+          } catch (e) {
+            uni.showToast({
+              title: '切换失败',
+              icon: 'none'
+            })
           }
         }
-      })
-    },
-    // 切换回家长模式
-    async switchBackToParentMode() {
-      try {
-        const result = await switchBackToParent()
-        if (result.data) {
-          this.currentChildId = ''
-          this.$store.commit('switchRole', 'parent')
-          uni.showToast({
-            title: '已切换回家长模式',
-            icon: 'success'
-          })
-          // 跳转到家长首页
-          uni.reLaunch({
-            url: '/pages/index/parent-index'
-          })
-        }
-      } catch (e) {
+      }
+    })
+  },
+  // 切换回家长模式(仅切换视图模式)
+  async switchBackToParentMode() {
+    try {
+      const result = await switchBackToParent()
+      if (result.data) {
+        this.currentChildId = ''
+        // 切换回家长视图模式
+        this.$store.commit('switchViewMode', 'parent')
+        uni.setStorageSync('currentRole', 'parent')
+        uni.removeStorageSync('currentChildId')
         uni.showToast({
-          title: '切换失败',
-          icon: 'none'
+          title: '已切换回家长模式',
+          icon: 'success'
+        })
+        // 跳转到家长首页
+        uni.reLaunch({
+          url: '/pages/index/parent-index'
         })
       }
-    },
+    } catch (e) {
+      uni.showToast({
+        title: '切换失败',
+        icon: 'none'
+      })
+    }
+  },
     async approveTask(taskId) {
       try {
         await approveTask(taskId)

+ 180 - 66
zxyj-frontend/pages/profile/edit-child.vue

@@ -3,16 +3,39 @@
     <view class="form-container">
       <view class="page-title">编辑孩子信息</view>
 
-      <view class="form-item">
-        <text class="label">孩子昵称</text>
-        <input class="input" v-model="form.nickname" placeholder="请输入孩子昵称" />
+      <!-- 姓名(必填) -->
+      <view class="form-item required">
+        <text class="label"><text class="required-mark">*</text>孩子姓名</text>
+        <input class="input" v-model="form.nickname" placeholder="请输入孩子姓名" />
+      </view>
+
+      <!-- 出生日期(必填) -->
+      <view class="form-item required">
+        <text class="label"><text class="required-mark">*</text>出生日期</text>
+        <picker mode="date" :value="form.birthday" @change="onBirthdayChange" :end="todayDate">
+          <view class="picker">
+            {{ form.birthday || '请选择出生日期' }}
+          </view>
+        </picker>
+      </view>
+
+      <!-- 性别(必填) -->
+      <view class="form-item required">
+        <text class="label"><text class="required-mark">*</text>性别</text>
+        <picker :range="genderOptions" range-key="label" @change="onGenderChange">
+          <view class="picker">
+            {{ genderLabel || '请选择性别' }}
+          </view>
+        </picker>
       </view>
 
+      <!-- 年龄(根据出生日期自动计算) -->
       <view class="form-item">
         <text class="label">年龄</text>
-        <input class="input" type="number" v-model="form.age" placeholder="请输入年龄" />
+        <view class="age-display">{{ calculatedAge || '根据出生日期自动计算' }}</view>
       </view>
 
+      <!-- DAN等级 -->
       <view class="form-item">
         <text class="label">DAN等级</text>
         <picker :range="danLevels" @change="onDanLevelChange">
@@ -22,12 +45,26 @@
         </picker>
       </view>
 
+      <!-- 手机号(非必填) -->
+      <view class="form-item">
+        <text class="label">手机号(选填)</text>
+        <input class="input" type="number" v-model="form.phone" placeholder="请输入手机号" maxlength="11" />
+      </view>
+
+      <!-- 身份证号(非必填) -->
+      <view class="form-item">
+        <text class="label">身份证号(选填)</text>
+        <input class="input" v-model="form.idCard" placeholder="请输入身份证号" maxlength="18" />
+      </view>
+
+      <!-- 扣分开关 -->
       <view class="form-item">
         <text class="label">扣分开关</text>
         <switch :checked="form.penaltyEnabled === 1" @change="onPenaltyChange" color="#FF6B6B" />
         <text class="hint">开启后,超时未完成任务将扣减积分</text>
       </view>
 
+      <!-- 主题风格 -->
       <view class="form-item">
         <text class="label">主题风格</text>
         <picker :range="themes" range-key="label" @change="onThemeChange">
@@ -38,8 +75,6 @@
       </view>
 
       <button class="btn-primary" :loading="submitting" @click="submit">保存修改</button>
-      
-      <button class="btn-delete" @click="deleteChild">删除孩子</button>
     </view>
   </view>
 </template>
@@ -54,12 +89,20 @@ export default {
       submitting: false,
       form: {
         nickname: '',
+        birthday: '',
+        gender: '',
         age: '',
         danLevel: '',
-        penaltyEnabled: 0,
+        phone: '',
+        idCard: '',
+        penaltyEnabled: 1,
         theme: 'default'
       },
       danLevels: ['A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6'],
+      genderOptions: [
+        { value: 'male', label: '男' },
+        { value: 'female', label: '女' }
+      ],
       themes: [
         { value: 'default', label: '默认' },
         { value: 'ocean', label: '海洋' },
@@ -72,43 +115,74 @@ export default {
     currentThemeLabel() {
       const theme = this.themes.find(t => t.value === this.form.theme)
       return theme ? theme.label : '默认'
+    },
+    genderLabel() {
+      const gender = this.genderOptions.find(g => g.value === this.form.gender)
+      return gender ? gender.label : ''
+    },
+    todayDate() {
+      return new Date().toISOString().split('T')[0]
+    },
+    calculatedAge() {
+      if (!this.form.birthday) return ''
+      const birth = new Date(this.form.birthday)
+      const today = new Date()
+      let age = today.getFullYear() - birth.getFullYear()
+      const monthDiff = today.getMonth() - birth.getMonth()
+      if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
+        age--
+      }
+      return age > 0 ? age + '岁' : ''
     }
   },
   onLoad(options) {
     this.childId = options.childId || ''
     if (!this.childId) {
       uni.showToast({ title: '参数错误', icon: 'none' })
-      setTimeout(() => uni.navigateBack(), 1200)
+      setTimeout(() => uni.navigateBack(), 1500)
       return
     }
-    this.loadChildDetail()
+    this.loadChildData()
   },
   methods: {
-    async loadChildDetail() {
+    async loadChildData() {
       try {
-        uni.showLoading({ title: '加载中...' })
         const res = await getChildren()
         const children = res.data || []
-        const targetChild = children.find(child => String(child.id) === String(this.childId))
-
-        if (!targetChild) {
-          uni.hideLoading()
-          uni.showToast({ title: '未找到孩子信息', icon: 'none' })
-          setTimeout(() => uni.navigateBack(), 1200)
-          return
+        const child = children.find(c => c.id == this.childId)
+        if (child) {
+          this.form = {
+            nickname: child.nickname || '',
+            birthday: child.birthday || '',
+            gender: child.gender || '',
+            age: child.age || '',
+            danLevel: child.danLevel || '',
+            phone: child.phone || '',
+            idCard: child.idCard || '',
+            penaltyEnabled: child.penaltyEnabled !== 0 ? 1 : 0,
+            theme: child.theme || 'default'
+          }
         }
-
-        this.form.nickname = targetChild.nickname || ''
-        this.form.age = targetChild.age != null ? String(targetChild.age) : ''
-        this.form.danLevel = targetChild.danLevel || ''
-        this.form.penaltyEnabled = targetChild.penaltyEnabled != null ? targetChild.penaltyEnabled : 0
-        this.form.theme = targetChild.theme || 'default'
-        uni.hideLoading()
       } catch (e) {
-        uni.hideLoading()
-        uni.showToast({ title: '加载孩子信息失败', icon: 'none' })
+        console.error('加载孩子数据失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
       }
     },
+    onBirthdayChange(e) {
+      this.form.birthday = e.detail.value
+      // 根据出生日期自动计算年龄
+      const birth = new Date(e.detail.value)
+      const today = new Date()
+      let age = today.getFullYear() - birth.getFullYear()
+      const monthDiff = today.getMonth() - birth.getMonth()
+      if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
+        age--
+      }
+      this.form.age = age
+    },
+    onGenderChange(e) {
+      this.form.gender = this.genderOptions[e.detail.value].value
+    },
     onDanLevelChange(e) {
       this.form.danLevel = this.danLevels[e.detail.value]
     },
@@ -120,65 +194,105 @@ export default {
     },
     async submit() {
       if (!this.form.nickname) {
-        uni.showToast({ title: '请输入孩子昵称', icon: 'none' })
+        uni.showToast({ title: '请输入孩子姓名', icon: 'none' })
         return
       }
-      if (!this.form.age) {
-        uni.showToast({ title: '请输入年龄', icon: 'none' })
+      if (!this.form.birthday) {
+        uni.showToast({ title: '请选择出生日期', icon: 'none' })
         return
       }
-
-      const age = parseInt(this.form.age, 10)
-      if (Number.isNaN(age) || age <= 0) {
-        uni.showToast({ title: '请输入正确的年龄', icon: 'none' })
+      if (!this.form.gender) {
+        uni.showToast({ title: '请选择性别', icon: 'none' })
         return
       }
 
+      this.submitting = true
       try {
-        this.submitting = true
-        const payload = {
+        await updateChild(this.childId, {
           nickname: this.form.nickname,
-          age,
-          danLevel: this.form.danLevel || 'B1',
+          birthday: this.form.birthday,
+          gender: this.form.gender,
+          age: this.form.age,
+          danLevel: this.form.danLevel,
+          phone: this.form.phone,
+          idCard: this.form.idCard,
           penaltyEnabled: this.form.penaltyEnabled,
           theme: this.form.theme
-        }
-
-        await updateChild(this.childId, payload)
-        uni.showToast({ title: '修改成功', icon: 'success' })
+        })
+        uni.showToast({ title: '保存成功', icon: 'success' })
         setTimeout(() => uni.navigateBack(), 1500)
       } catch (e) {
-        uni.showToast({ title: '修改失败', icon: 'none' })
+        console.error('保存失败', e)
+        uni.showToast({ title: '保存失败', icon: 'none' })
       } finally {
         this.submitting = false
       }
-    },
-    deleteChild() {
-      uni.showModal({
-        title: '确认删除',
-        content: `确定要删除 ${this.form.nickname} 吗?删除后无法恢复。`,
-        confirmColor: '#FF6B6B',
-        success: (res) => {
-          if (res.confirm) {
-            // TODO: 调用删除接口(后端需新增)
-            uni.showToast({ title: '删除功能暂未实现', icon: 'none' })
-          }
-        }
-      })
     }
   }
 }
 </script>
 
 <style scoped>
-.container { padding: 30rpx; }
-.form-container { background: #fff; border-radius: 20rpx; padding: 30rpx; }
-.page-title { font-size: 34rpx; font-weight: bold; color: #333; margin-bottom: 30rpx; }
-.form-item { margin-bottom: 30rpx; }
-.label { display: block; font-size: 28rpx; color: #333; margin-bottom: 10rpx; }
-.input { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; }
-.picker { border: 1rpx solid #ddd; border-radius: 10rpx; padding: 20rpx; font-size: 28rpx; color: #666; }
-.hint { font-size: 24rpx; color: #999; margin-left: 20rpx; }
-.btn-primary { background: #FF6B6B; color: #fff; border-radius: 20rpx; margin-top: 40rpx; }
-.btn-delete { background: #fff; color: #FF6B6B; border: 1rpx solid #FF6B6B; border-radius: 20rpx; margin-top: 20rpx; }
+.container {
+  padding: 30rpx;
+}
+
+.form-container {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+}
+
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  text-align: center;
+  margin-bottom: 40rpx;
+}
+
+.form-item {
+  margin-bottom: 30rpx;
+}
+
+.form-item.required .label {
+  font-weight: bold;
+}
+
+.required-mark {
+  color: #FF6B6B;
+  margin-right: 8rpx;
+}
+
+.label {
+  display: block;
+  font-size: 28rpx;
+  color: #333;
+  margin-bottom: 10rpx;
+}
+
+.input, .picker {
+  border: 1rpx solid #ddd;
+  border-radius: 10rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+}
+
+.age-display {
+  font-size: 28rpx;
+  color: #666;
+  padding: 20rpx;
+}
+
+.hint {
+  display: block;
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 10rpx;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, #FF6B6B, #FF8E53);
+  color: #fff;
+  margin-top: 40rpx;
+}
 </style>

+ 7 - 3
zxyj-frontend/pages/rewards/rewards.vue

@@ -63,7 +63,7 @@
 </template>
 
 <script>
-import { getWishlist, exchangeReward, createReward, getPointsBalance, getChildren, getParentWishlist, createParentWishlist, exchangeParentWishlist } from '../../utils/api.js'
+import { getWishlist, exchangeReward as exchangeRewardApi, createReward as createRewardApi, getPointsBalance, getChildren, getParentWishlist, createParentWishlist, exchangeParentWishlist } from '../../utils/api.js'
 
 export default {
   data() {
@@ -82,6 +82,10 @@ export default {
   onShow() {
     // 获取当前角色
     this.currentRole = uni.getStorageSync('role') || 'parent'
+    if (this.currentRole !== 'parent') {
+      uni.reLaunch({ url: '/pages/child/rewards' })
+      return
+    }
     this.loadRewards()
   },
   methods: {
@@ -141,7 +145,7 @@ export default {
               if (this.currentRole === 'parent') {
                 await exchangeParentWishlist(rewardId)
               } else {
-                await exchangeReward(rewardId, this.currentChildId)
+                await exchangeRewardApi(rewardId, this.currentChildId)
               }
               uni.showToast({ title: '兑换成功', icon: 'success' })
               this.loadRewards()
@@ -172,7 +176,7 @@ export default {
           uni.showToast({ title: '添加成功', icon: 'success' })
         } else {
           // 孩子模式:创建孩子心愿,积分为0,等待家长填写
-          await createReward({
+          await createRewardApi({
             title: this.newReward.title,
             childId: this.currentChildId
             // pointsRequired不传,默认为0

+ 45 - 8
zxyj-frontend/pages/tasks/tasks.vue

@@ -20,7 +20,7 @@
           <button 
             v-if="task.status === 'pending'" 
             class="btn-complete" 
-            @click="completeTask(task.id)"
+            @click="isParent ? completeParentTask(task.id) : completeTask(task.id)"
           >
             完成
           </button>
@@ -28,7 +28,7 @@
         </view>
       </view>
     </view>
-    
+
     <view class="empty" v-else>
       <text>暂无今日任务</text>
     </view>
@@ -167,7 +167,7 @@
 </template>
 
 <script>
-import { getTodayTasks, completeTask, createTask, getChildren } from '../../utils/api.js'
+import { getTodayTasks, completeTask as completeTaskApi, createTask as createTaskApi, getChildren, getTodayParentTasks, completeParentTask as completeParentTaskApi } from '../../utils/api.js'
 import PlayfulCard from '@/components/PlayfulCard.vue'
 
 export default {
@@ -176,6 +176,7 @@ export default {
     return {
       tasks: [],
       isParent: false,
+      currentRole: 'parent',  // 当前角色
       currentChildId: '',
       children: [],  // 孩子列表
       taskTemplates: [],  // 任务模板
@@ -215,9 +216,22 @@ export default {
     }
   },
   onShow() {
-    this.loadTasks()
-    this.loadChildren()
-    this.isParent = this.$store.state.role === 'parent'
+    // 从存储中获取当前角色
+    this.currentRole = uni.getStorageSync('role') || 'parent'
+    this.isParent = this.currentRole === 'parent'
+    if (!this.isParent) {
+      uni.reLaunch({ url: '/pages/child/tasks' })
+      return
+    }
+
+    if (this.isParent) {
+      // 家长模式:加载家长任务(分配给家长的任务)
+      this.loadParentTasks()
+    } else {
+      // 孩子模式:加载孩子的任务
+      this.loadChildren()
+      this.loadTasks()
+    }
   },
   methods: {
     async loadChildren() {
@@ -232,6 +246,15 @@ export default {
         console.error('加载孩子失败', e)
       }
     },
+    // 加载家长任务(分配给家长的任务)
+    async loadParentTasks() {
+      try {
+        const res = await getTodayParentTasks()
+        this.tasks = res.data || []
+      } catch (e) {
+        console.error('加载家长任务失败', e)
+      }
+    },
     async loadTasks() {
       try {
         const childrenRes = await getChildren()
@@ -245,9 +268,10 @@ export default {
         console.error('加载任务失败', e)
       }
     },
+    // 完成任务(孩子模式)
     async completeTask(taskId) {
       try {
-        const res = await completeTask(taskId, this.currentChildId, '')
+        const res = await completeTaskApi(taskId, this.currentChildId, '')
         uni.showToast({
           title: `获得 ${res.data.pointsEarned} 积分`,
           icon: 'success'
@@ -257,6 +281,19 @@ export default {
         console.error('完成任务失败', e)
       }
     },
+    // 完成任务(家长模式)
+    async completeParentTask(taskId) {
+      try {
+        const res = await completeParentTaskApi(taskId)
+        uni.showToast({
+          title: `获得 ${res.data.pointsEarned} 积分`,
+          icon: 'success'
+        })
+        this.loadParentTasks()
+      } catch (e) {
+        console.error('完成任务失败', e)
+      }
+    },
     // 模板选择
     onTemplateChange(e) {
       if (e.detail.value >= 0) {
@@ -334,7 +371,7 @@ export default {
       }
 
       try {
-        await createTask({
+        await createTaskApi({
           title: this.newTask.title,
           description: this.newTask.description,
           category: this.newTask.category,

+ 211 - 0
zxyj-frontend/pages/wishes/create.vue

@@ -0,0 +1,211 @@
+<template>
+  <view class="container">
+    <view class="form-card">
+      <view class="form-title">添加心愿</view>
+      
+      <!-- 心愿名称 -->
+      <view class="form-item">
+        <view class="form-label">心愿名称 <text class="required">*</text></view>
+        <input 
+          v-model="form.title" 
+          placeholder="请输入心愿名称"
+          maxlength="50"
+        />
+      </view>
+      
+      <!-- 分类 -->
+      <view class="form-item">
+        <view class="form-label">分类</view>
+        <view class="category-list">
+          <view 
+            v-for="cat in categories" 
+            :key="cat.value"
+            class="category-item"
+            :class="{ active: form.category === cat.value }"
+            @click="form.category = cat.value"
+          >
+            {{ cat.label }}
+          </view>
+        </view>
+      </view>
+      
+      <!-- 详细描述 -->
+      <view class="form-item">
+        <view class="form-label">详细描述</view>
+        <textarea 
+          v-model="form.description" 
+          placeholder="描述一下这个心愿(可选)"
+          maxlength="200"
+        />
+      </view>
+      
+      <!-- 提示 -->
+      <view class="form-tip">
+        <text class="tip-icon">💡</text>
+        <text class="tip-text">创建心愿后,家长会为它设定所需积分</text>
+      </view>
+    </view>
+    
+    <!-- 提交按钮 -->
+    <button class="btn-submit" @click="submitForm" :loading="submitting">
+      提交心愿
+    </button>
+  </view>
+</template>
+
+<script>
+import { createWish } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      form: {
+        title: '',
+        description: '',
+        category: ''
+      },
+      categories: [
+        { label: '玩具', value: '玩具' },
+        { label: '图书', value: '图书' },
+        { label: '美食', value: '美食' },
+        { label: '娱乐', value: '娱乐' },
+        { label: '活动', value: '活动' },
+        { label: '其他', value: '其他' }
+      ],
+      submitting: false
+    }
+  },
+  methods: {
+    async submitForm() {
+      // 验证
+      if (!this.form.title || !this.form.title.trim()) {
+        uni.showToast({ title: '请输入心愿名称', icon: 'none' })
+        return
+      }
+      
+      this.submitting = true
+      try {
+        await createWish({
+          title: this.form.title.trim(),
+          description: this.form.description,
+          category: this.form.category
+        })
+        
+        uni.showToast({ title: '提交成功', icon: 'success' })
+        
+        // 返回上一页
+        setTimeout(() => {
+          uni.navigateBack()
+        }, 1500)
+      } catch (e) {
+        uni.showToast({ title: e.message || '提交失败', icon: 'none' })
+      } finally {
+        this.submitting = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  padding: 30rpx;
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+
+.form-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 40rpx;
+  margin-bottom: 30rpx;
+}
+
+.form-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 40rpx;
+  text-align: center;
+}
+
+.form-item {
+  margin-bottom: 30rpx;
+}
+
+.form-label {
+  font-size: 28rpx;
+  color: #333;
+  margin-bottom: 15rpx;
+  display: block;
+}
+
+.required {
+  color: #FF6B6B;
+}
+
+.form-item input,
+.form-item textarea {
+  width: 100%;
+  background: #f5f5f5;
+  border-radius: 15rpx;
+  padding: 20rpx;
+  font-size: 28rpx;
+  border: none;
+}
+
+.form-item textarea {
+  min-height: 150rpx;
+}
+
+/* 分类选择 */
+.category-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 15rpx;
+}
+
+.category-item {
+  padding: 15rpx 30rpx;
+  background: #f5f5f5;
+  border-radius: 30rpx;
+  font-size: 26rpx;
+  color: #666;
+}
+
+.category-item.active {
+  background: linear-gradient(135deg, #FF6B6B, #FF8E53);
+  color: #fff;
+}
+
+/* 提示 */
+.form-tip {
+  display: flex;
+  align-items: center;
+  background: #FFF5F5;
+  padding: 20rpx;
+  border-radius: 15rpx;
+  margin-top: 20rpx;
+}
+
+.tip-icon {
+  font-size: 32rpx;
+  margin-right: 15rpx;
+}
+
+.tip-text {
+  font-size: 24rpx;
+  color: #666;
+  flex: 1;
+}
+
+/* 提交按钮 */
+.btn-submit {
+  background: linear-gradient(135deg, #FF6B6B, #FF8E53);
+  color: #fff;
+  font-size: 32rpx;
+  padding: 24rpx;
+  border-radius: 50rpx;
+  margin-top: 40rpx;
+}
+</style>

+ 409 - 0
zxyj-frontend/pages/wishes/detail.vue

@@ -0,0 +1,409 @@
+<template>
+  <view class="container" v-if="wish">
+    <!-- 心愿详情卡片 -->
+    <view class="wish-card" :class="wish.status">
+      <view class="wish-header">
+        <text class="wish-title">{{ wish.title }}</text>
+        <text class="wish-status" :class="wish.status">{{ wish.statusText }}</text>
+      </view>
+      
+      <!-- 分类 -->
+      <view class="wish-category" v-if="wish.category">
+        <text class="category-tag">{{ wish.category }}</text>
+      </view>
+      
+      <!-- 描述 -->
+      <view class="wish-desc" v-if="wish.description">
+        <text>{{ wish.description }}</text>
+      </view>
+      
+      <!-- 积分信息 -->
+      <view class="points-section" v-if="wish.pointsRequired">
+        <view class="points-label">所需积分</view>
+        <view class="points-value">
+          <text class="points-num">{{ wish.pointsRequired }}</text>
+          <text class="points-unit">分</text>
+        </view>
+        
+        <!-- 进度条 -->
+        <view class="progress-section" v-if="wish.status === 'active'">
+          <view class="progress-bar">
+            <view class="progress-fill" :style="{ width: wish.progress + '%' }"></view>
+          </view>
+          <text class="progress-text">
+            当前积分: {{ currentPoints }}分
+          </text>
+        </view>
+      </view>
+      
+      <!-- 时间信息 -->
+      <view class="time-info">
+        <text class="time-text">创建于 {{ wish.createdAt }}</text>
+      </view>
+    </view>
+    
+    <!-- 操作按钮区 -->
+    <view class="action-section">
+      <!-- 积攒中:积分足够可申请兑换 -->
+      <button 
+        v-if="wish.status === 'active' && currentPoints >= wish.pointsRequired" 
+        class="btn-primary"
+        @click="requestExchange"
+      >
+        申请兑换 ({{ wish.pointsRequired }}积分)
+      </button>
+      
+      <!-- 积攒中:积分不足 -->
+      <button 
+        v-if="wish.status === 'active' && currentPoints < wish.pointsRequired" 
+        class="btn-disabled"
+        disabled
+      >
+        还差{{ wish.pointsRequired - currentPoints }}积分可兑换
+      </button>
+      
+      <!-- 待审批 -->
+      <view v-if="wish.status === 'pending_exchange'" class="waiting-box">
+        <text class="waiting-icon">⏳</text>
+        <text class="waiting-text">等待家长审批中...</text>
+      </view>
+      
+      <!-- 已批准,等待兑现 -->
+      <view v-if="wish.status === 'approved'" class="waiting-box">
+        <text class="waiting-icon">📦</text>
+        <text class="waiting-text">家长正在准备中,请耐心等待</text>
+      </view>
+      
+      <!-- 待收货:确认收货按钮 -->
+      <button 
+        v-if="wish.status === 'exchanging'" 
+        class="btn-primary"
+        @click="confirmReceived"
+      >
+        确认收货
+      </button>
+      
+      <!-- 已完成 -->
+      <view v-if="wish.status === 'fulfilled'" class="fulfilled-box">
+        <text class="fulfilled-icon">🎉</text>
+        <text class="fulfilled-text">心愿已完成!</text>
+        <text class="fulfilled-time">{{ wish.fulfilledAt }}</text>
+      </view>
+      
+      <!-- 待定价 -->
+      <view v-if="wish.status === 'draft'" class="waiting-box">
+        <text class="waiting-icon">📝</text>
+        <text class="waiting-text">等待家长定价中...</text>
+      </view>
+      
+      <!-- 取消按钮(draft或active状态可取消) -->
+      <button 
+        v-if="wish.status === 'draft' || wish.status === 'active'" 
+        class="btn-cancel"
+        @click="cancelWish"
+      >
+        取消心愿
+      </button>
+    </view>
+  </view>
+  
+  <!-- 加载中 -->
+  <view class="loading" v-else>
+    <text>加载中...</text>
+  </view>
+</template>
+
+<script>
+import { 
+  getWishById, 
+  requestWishExchange, 
+  confirmWishReceived,
+  cancelWish as cancelWishApi,
+  getPointsBalance 
+} from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      wishId: null,
+      wish: null,
+      currentPoints: 0,
+      childId: ''
+    }
+  },
+  onLoad(options) {
+    this.wishId = options.id
+    this.loadData()
+  },
+  methods: {
+    async loadData() {
+      try {
+        // 获取心愿详情
+        const res = await getWishById(this.wishId)
+        this.wish = res.data
+        
+        // 获取当前积分
+        this.childId = uni.getStorageSync('currentChildId') || uni.getStorageSync('userId')
+        const balanceRes = await getPointsBalance(this.childId)
+        this.currentPoints = balanceRes.data?.balance || 0
+      } catch (e) {
+        console.error('加载心愿详情失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      }
+    },
+    
+    async requestExchange() {
+      uni.showModal({
+        title: '申请兑换',
+        content: `确定要兑换"${this.wish.title}"吗?\n将消耗${this.wish.pointsRequired}积分`,
+        success: async (res) => {
+          if (res.confirm) {
+            try {
+              await requestWishExchange(this.wishId)
+              uni.showToast({ title: '已提交申请', icon: 'success' })
+              this.loadData()
+            } catch (e) {
+              uni.showToast({ title: e.message || '申请失败', icon: 'none' })
+            }
+          }
+        }
+      })
+    },
+    
+    async confirmReceived() {
+      uni.showModal({
+        title: '确认收货',
+        content: `确定已收到"${this.wish.title}"吗?`,
+        success: async (res) => {
+          if (res.confirm) {
+            try {
+              await confirmWishReceived(this.wishId)
+              uni.showToast({ title: '心愿已完成!', icon: 'success' })
+              this.loadData()
+            } catch (e) {
+              uni.showToast({ title: '确认失败', icon: 'none' })
+            }
+          }
+        }
+      })
+    },
+    
+    async cancelWish() {
+      uni.showModal({
+        title: '取消心愿',
+        content: `确定要取消"${this.wish.title}"吗?`,
+        success: async (res) => {
+          if (res.confirm) {
+            try {
+              await cancelWishApi(this.wishId, '不想兑换了')
+              uni.showToast({ title: '已取消', icon: 'success' })
+              setTimeout(() => {
+                uni.navigateBack()
+              }, 1500)
+            } catch (e) {
+              uni.showToast({ title: '取消失败', icon: 'none' })
+            }
+          }
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  padding: 30rpx;
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+
+.wish-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 40rpx;
+  margin-bottom: 30rpx;
+}
+
+.wish-card.draft { border-left: 8rpx solid #999; }
+.wish-card.active { border-left: 8rpx solid #10B981; }
+.wish-card.pending_exchange { border-left: 8rpx solid #F59E0B; }
+.wish-card.approved,
+.wish-card.exchanging { border-left: 8rpx solid #3B82F6; }
+.wish-card.fulfilled { border-left: 8rpx solid #8B5CF6; }
+
+.wish-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 20rpx;
+}
+
+.wish-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  flex: 1;
+}
+
+.wish-status {
+  font-size: 24rpx;
+  padding: 8rpx 20rpx;
+  border-radius: 20rpx;
+  background: #f0f0f0;
+  color: #666;
+}
+
+.wish-status.active { background: #D1FAE5; color: #059669; }
+.wish-status.pending_exchange { background: #FEF3C7; color: #D97706; }
+.wish-status.exchanging { background: #DBEAFE; color: #2563EB; }
+.wish-status.fulfilled { background: #EDE9FE; color: #7C3AED; }
+
+.category-tag {
+  font-size: 24rpx;
+  color: #666;
+  background: #f5f5f5;
+  padding: 6rpx 16rpx;
+  border-radius: 12rpx;
+  display: inline-block;
+  margin-bottom: 20rpx;
+}
+
+.wish-desc {
+  font-size: 28rpx;
+  color: #666;
+  line-height: 1.6;
+  margin-bottom: 20rpx;
+  padding: 20rpx;
+  background: #f9f9f9;
+  border-radius: 12rpx;
+}
+
+.points-section {
+  background: linear-gradient(135deg, #FF6B6B, #FF8E53);
+  border-radius: 15rpx;
+  padding: 25rpx;
+  color: #fff;
+  margin-bottom: 20rpx;
+}
+
+.points-label {
+  font-size: 24rpx;
+  opacity: 0.9;
+}
+
+.points-value {
+  margin-top: 10rpx;
+}
+
+.points-num {
+  font-size: 48rpx;
+  font-weight: bold;
+}
+
+.points-unit {
+  font-size: 24rpx;
+  margin-left: 5rpx;
+}
+
+.progress-section {
+  margin-top: 20rpx;
+}
+
+.progress-bar {
+  height: 12rpx;
+  background: rgba(255,255,255,0.3);
+  border-radius: 6rpx;
+  overflow: hidden;
+}
+
+.progress-fill {
+  height: 100%;
+  background: #fff;
+  border-radius: 6rpx;
+}
+
+.progress-text {
+  font-size: 22rpx;
+  margin-top: 10rpx;
+  display: block;
+}
+
+.time-info {
+  margin-top: 20rpx;
+}
+
+.time-text {
+  font-size: 24rpx;
+  color: #999;
+}
+
+/* 操作区域 */
+.action-section {
+  padding: 20rpx;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, #FF6B6B, #FF8E53);
+  color: #fff;
+  font-size: 32rpx;
+  padding: 24rpx;
+  border-radius: 50rpx;
+  margin-bottom: 20rpx;
+}
+
+.btn-disabled {
+  background: #e5e7eb;
+  color: #9ca3af;
+  font-size: 32rpx;
+  padding: 24rpx;
+  border-radius: 50rpx;
+  margin-bottom: 20rpx;
+}
+
+.btn-cancel {
+  background: #fff;
+  color: #999;
+  font-size: 28rpx;
+  padding: 20rpx;
+  border-radius: 40rpx;
+  border: 1rpx solid #ddd;
+}
+
+.waiting-box,
+.fulfilled-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 40rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  margin-bottom: 20rpx;
+}
+
+.waiting-icon,
+.fulfilled-icon {
+  font-size: 80rpx;
+  margin-bottom: 20rpx;
+}
+
+.waiting-text,
+.fulfilled-text {
+  font-size: 30rpx;
+  color: #333;
+  margin-bottom: 10rpx;
+}
+
+.fulfilled-time {
+  font-size: 24rpx;
+  color: #8B5CF6;
+}
+
+.loading {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 100vh;
+  color: #999;
+}
+</style>

+ 387 - 0
zxyj-frontend/pages/wishes/index.vue

@@ -0,0 +1,387 @@
+<template>
+  <view class="container">
+    <!-- 空状态:没有心愿 -->
+    <view class="empty-state" v-if="wishes.length === 0 && !loading">
+      <view class="empty-icon">🎁</view>
+      <text class="empty-title">还没有心愿</text>
+      <text class="empty-desc">快去添加心愿,开始积攒积分吧!</text>
+      <button class="btn-add" @click="goCreate">添加心愿</button>
+    </view>
+
+    <!-- 心愿列表 -->
+    <view class="wish-list" v-else>
+      <view 
+        v-for="wish in wishes" 
+        :key="wish.id" 
+        class="wish-card"
+        :class="wish.status"
+        @click="goDetail(wish.id)"
+      >
+        <!-- 心愿头部 -->
+        <view class="wish-header">
+          <text class="wish-title">{{ wish.title }}</text>
+          <text class="wish-status" :class="wish.status">{{ wish.statusText }}</text>
+        </view>
+
+        <!-- 分类标签 -->
+        <view class="wish-category" v-if="wish.category">
+          <text class="category-tag">{{ wish.category }}</text>
+        </view>
+
+        <!-- 积分进度(active状态显示) -->
+        <view v-if="wish.status === 'active'" class="progress-section">
+          <view class="progress-bar">
+            <view class="progress-fill" :style="{ width: wish.progress + '%' }"></view>
+          </view>
+          <text class="progress-text">
+            {{ currentPoints }}/{{ wish.pointsRequired }}积分
+          </text>
+        </view>
+
+        <!-- 操作按钮 -->
+        <view class="wish-actions">
+          <!-- 积攒中:积分足够可申请兑换 -->
+          <button 
+            v-if="wish.status === 'active' && currentPoints >= wish.pointsRequired" 
+            class="btn-exchange"
+            @click.stop="requestExchange(wish)"
+          >
+            申请兑换
+          </button>
+          
+          <!-- 积攒中:积分不足显示差多少 -->
+          <button 
+            v-if="wish.status === 'active' && currentPoints < wish.pointsRequired" 
+            class="btn-waiting"
+            disabled
+          >
+            还差{{ wish.pointsRequired - currentPoints }}积分
+          </button>
+
+          <!-- 待审批:显示等待中 -->
+          <button 
+            v-if="wish.status === 'pending_exchange'" 
+            class="btn-waiting"
+            disabled
+          >
+            等待家长审批
+          </button>
+
+          <!-- 待收货:确认收货按钮 -->
+          <button 
+            v-if="wish.status === 'exchanging'" 
+            class="btn-confirm"
+            @click.stop="confirmReceived(wish)"
+          >
+            确认收货
+          </button>
+
+          <!-- 已完成:显示完成时间 -->
+          <text v-if="wish.status === 'fulfilled'" class="fulfilled-text">
+            ✅ 已于{{ wish.fulfilledAt }}完成
+          </text>
+
+          <!-- 待定价:显示等待家长 -->
+          <button 
+            v-if="wish.status === 'draft'" 
+            class="btn-waiting"
+            disabled
+          >
+            等待家长定价
+          </button>
+        </view>
+      </view>
+    </view>
+
+    <!-- 添加按钮 -->
+    <button class="btn-add-fixed" @click="goCreate" v-if="wishes.length > 0">
+      + 添加心愿
+    </button>
+  </view>
+</template>
+
+<script>
+import { 
+  getWishes, 
+  requestWishExchange, 
+  confirmWishReceived,
+  getPointsBalance 
+} from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      wishes: [],
+      currentPoints: 0,
+      childId: '',
+      loading: true
+    }
+  },
+  onShow() {
+    this.loadData()
+  },
+  methods: {
+    async loadData() {
+      this.loading = true
+      try {
+        // 获取当前孩子ID
+        const currentRole = uni.getStorageSync('currentRole') || uni.getStorageSync('role')
+        if (currentRole !== 'child') {
+          // 如果不是孩子模式,跳转
+          uni.reLaunch({ url: '/pages/index/index' })
+          return
+        }
+        
+        this.childId = uni.getStorageSync('currentChildId') || uni.getStorageSync('userId')
+        
+        // 获取心愿列表
+        const res = await getWishes({ childId: this.childId })
+        this.wishes = res.data || []
+        
+        // 获取当前积分
+        const balanceRes = await getPointsBalance(this.childId)
+        this.currentPoints = balanceRes.data?.balance || 0
+      } catch (e) {
+        console.error('加载心愿列表失败', e)
+        uni.showToast({ title: '加载失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    },
+    
+    async requestExchange(wish) {
+      uni.showModal({
+        title: '申请兑换',
+        content: `确定要兑换"${wish.title}"吗?\n将消耗${wish.pointsRequired}积分`,
+        success: async (res) => {
+          if (res.confirm) {
+            try {
+              await requestWishExchange(wish.id)
+              uni.showToast({ title: '已提交申请', icon: 'success' })
+              this.loadData()
+            } catch (e) {
+              uni.showToast({ title: e.message || '申请失败', icon: 'none' })
+            }
+          }
+        }
+      })
+    },
+    
+    async confirmReceived(wish) {
+      uni.showModal({
+        title: '确认收货',
+        content: `确定已收到"${wish.title}"吗?`,
+        success: async (res) => {
+          if (res.confirm) {
+            try {
+              await confirmWishReceived(wish.id)
+              uni.showToast({ title: '心愿已完成!', icon: 'success' })
+              this.loadData()
+            } catch (e) {
+              uni.showToast({ title: '确认失败', icon: 'none' })
+            }
+          }
+        }
+      })
+    },
+    
+    goCreate() {
+      uni.navigateTo({ url: '/pages/wishes/create' })
+    },
+    
+    goDetail(id) {
+      uni.navigateTo({ url: `/pages/wishes/detail?id=${id}` })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  padding: 30rpx;
+  padding-bottom: 150rpx;
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+
+/* 空状态 */
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 150rpx 60rpx;
+  text-align: center;
+}
+
+.empty-icon {
+  font-size: 120rpx;
+  margin-bottom: 30rpx;
+}
+
+.empty-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 20rpx;
+}
+
+.empty-desc {
+  font-size: 28rpx;
+  color: #666;
+  margin-bottom: 40rpx;
+}
+
+.btn-add {
+  background: linear-gradient(135deg, #FF6B6B, #FF8E53);
+  color: #fff;
+  border-radius: 40rpx;
+  padding: 20rpx 60rpx;
+  font-size: 30rpx;
+}
+
+/* 心愿卡片 */
+.wish-card {
+  background: #fff;
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 20rpx;
+  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.05);
+}
+
+.wish-card.draft {
+  border-left: 8rpx solid #999;
+}
+
+.wish-card.active {
+  border-left: 8rpx solid #10B981;
+}
+
+.wish-card.pending_exchange {
+  border-left: 8rpx solid #F59E0B;
+}
+
+.wish-card.approved,
+.wish-card.exchanging {
+  border-left: 8rpx solid #3B82F6;
+}
+
+.wish-card.fulfilled {
+  border-left: 8rpx solid #8B5CF6;
+  opacity: 0.8;
+}
+
+.wish-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15rpx;
+}
+
+.wish-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+  flex: 1;
+}
+
+.wish-status {
+  font-size: 24rpx;
+  padding: 6rpx 16rpx;
+  border-radius: 20rpx;
+  background: #f0f0f0;
+  color: #666;
+}
+
+.wish-status.active { background: #D1FAE5; color: #059669; }
+.wish-status.pending_exchange { background: #FEF3C7; color: #D97706; }
+.wish-status.exchanging { background: #DBEAFE; color: #2563EB; }
+.wish-status.fulfilled { background: #EDE9FE; color: #7C3AED; }
+
+.category-tag {
+  font-size: 22rpx;
+  color: #666;
+  background: #f5f5f5;
+  padding: 4rpx 12rpx;
+  border-radius: 10rpx;
+}
+
+/* 进度条 */
+.progress-section {
+  margin: 20rpx 0;
+}
+
+.progress-bar {
+  height: 16rpx;
+  background: #e5e7eb;
+  border-radius: 8rpx;
+  overflow: hidden;
+}
+
+.progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #10B981, #34D399);
+  border-radius: 8rpx;
+  transition: width 0.3s;
+}
+
+.progress-text {
+  font-size: 24rpx;
+  color: #666;
+  margin-top: 10rpx;
+  display: block;
+}
+
+/* 操作按钮 */
+.wish-actions {
+  margin-top: 20rpx;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.btn-exchange {
+  background: linear-gradient(135deg, #FF6B6B, #FF8E53);
+  color: #fff;
+  font-size: 26rpx;
+  padding: 12rpx 30rpx;
+  border-radius: 30rpx;
+  margin: 0;
+}
+
+.btn-waiting {
+  background: #e5e7eb;
+  color: #9ca3af;
+  font-size: 26rpx;
+  padding: 12rpx 30rpx;
+  border-radius: 30rpx;
+  margin: 0;
+}
+
+.btn-confirm {
+  background: linear-gradient(135deg, #3B82F6, #2563EB);
+  color: #fff;
+  font-size: 26rpx;
+  padding: 12rpx 30rpx;
+  border-radius: 30rpx;
+  margin: 0;
+}
+
+.fulfilled-text {
+  font-size: 24rpx;
+  color: #8B5CF6;
+}
+
+/* 固定添加按钮 */
+.btn-add-fixed {
+  position: fixed;
+  bottom: 30rpx;
+  left: 30rpx;
+  right: 30rpx;
+  background: linear-gradient(135deg, #FF6B6B, #FF8E53);
+  color: #fff;
+  font-size: 32rpx;
+  padding: 24rpx;
+  border-radius: 50rpx;
+  box-shadow: 0 4rpx 20rpx rgba(255,107,107,0.3);
+}
+</style>

+ 18 - 3
zxyj-frontend/store/index.js

@@ -7,7 +7,8 @@ const store = new Vuex.Store({
   state: {
     token: '',
     userId: '',
-    role: 'parent',
+    role: 'parent', // 用户的实际角色(数据库中的角色)
+    currentRole: 'parent', // 当前视图模式(用于切换查看家长/孩子界面)
     familyId: '',
     nickname: '',
     currentChildId: '',
@@ -21,8 +22,14 @@ const store = new Vuex.Store({
     setUserInfo(state, userInfo) {
       state.userId = userInfo.userId
       state.role = userInfo.role
+      state.currentRole = userInfo.role // 初始化当前角色为实际角色
       state.familyId = userInfo.familyId
       state.nickname = userInfo.nickname
+      // 保存到本地存储
+      uni.setStorageSync('userId', userInfo.userId)
+      uni.setStorageSync('role', userInfo.role)
+      uni.setStorageSync('currentRole', userInfo.role)
+      uni.setStorageSync('familyId', userInfo.familyId)
     },
     setChildren(state, children) {
       state.children = children
@@ -34,19 +41,27 @@ const store = new Vuex.Store({
       state.currentChildId = childId
       uni.setStorageSync('currentChildId', childId)
     },
+    // 切换视图模式(不修改数据库中的实际角色)
+    switchViewMode(state, role) {
+      state.currentRole = role
+      uni.setStorageSync('currentRole', role)
+    },
+    // 兼容旧的方法名
     switchRole(state, role) {
-      state.role = role
-      uni.setStorageSync('role', role)
+      state.currentRole = role
+      uni.setStorageSync('currentRole', role)
     },
     logout(state) {
       state.token = ''
       state.userId = ''
       state.role = 'parent'
+      state.currentRole = 'parent'
       state.familyId = ''
       state.nickname = ''
       state.currentChildId = ''
       state.children = []
       uni.removeStorageSync('token')
+      uni.removeStorageSync('currentRole')
     }
   },
   actions: {