Compare commits

...

2 Commits

Author SHA1 Message Date
神码-方晓辉 729f2c71f1 feat: 会员教育信息重构 - 支持多教育身份管理
主要变更:
1. 去掉身份类型字段,会员与教育信息改为一对多关系
2. 新增 pg_education 表及相关实体、服务
3. 管理后台支持添加、编辑、删除多个教育身份
4. 新增会员时支持一次性保存会员信息、教育身份、亲子关系
5. 修复学生选择弹窗学校年级班级不显示问题
6. 添加区域路径接口用于级联选择器回显
2026-02-03 16:35:57 +08:00
神码-方晓辉 acdade6725 feat: 添加微信登录接口和技术方案文档 2026-02-03 15:31:56 +08:00
40 changed files with 3561 additions and 549 deletions

View File

@ -314,3 +314,27 @@ h5:
blacklist-minutes: 30
# 连续验证失败多少次后加入黑名单
blacklist-trigger-count: 5
# ============================================================
# H5微信扫码登录配置
# 微信开放平台: https://open.weixin.qq.com/
# 需要创建"网站应用"并获取AppID和AppSecret
# ============================================================
wechat:
# ---------- 基础配置 ----------
# 是否启用微信登录
enabled: false
# 微信开放平台AppID网站应用
app-id: ${H5_WECHAT_APP_ID:your-app-id}
# 微信开放平台AppSecret
app-secret: ${H5_WECHAT_APP_SECRET:your-app-secret}
# ---------- 二维码配置 ----------
# 二维码有效期默认5分钟
qrcode-expire-seconds: 300
# ---------- 绑定配置 ----------
# 绑定凭证有效期分钟默认10分钟
bind-token-expire-minutes: 10
# 首次登录是否自动注册会员(需绑定手机号后注册)
auto-register: true

View File

@ -51,6 +51,15 @@ public class PgRegionController extends BaseController {
return R.ok(regionService.selectById(regionId));
}
/**
* 获取区域路径从根到当前区域的ID列表
* 用于级联选择器回显
*/
@GetMapping("/{regionId}/path")
public R<List<Long>> getRegionPath(@PathVariable Long regionId) {
return R.ok(regionService.getRegionPath(regionId));
}
@SaCheckPermission("business:region:add")
@Log(title = "区域管理", businessType = BusinessType.INSERT)
@PostMapping

View File

@ -0,0 +1,67 @@
package org.dromara.pangu.base.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.domain.BaseEntity;
/**
* 教育信息表会员教育身份
*
* @author 湖北新华业务中台研发团队
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("pg_education")
public class PgEducation extends BaseEntity {
@TableId(type = IdType.AUTO)
private Long educationId;
private Long memberId;
private Long regionId;
private Long schoolId;
private Long schoolGradeId;
private Long schoolClassId;
private Long subjectId;
/**
* 是否默认身份0否 1是
*/
private String isDefault;
/**
* 状态0正常 1停用
*/
private String status;
private String tenantId;
@TableLogic
private String delFlag;
// 非数据库字段用于VO展示
@TableField(exist = false)
private String regionName;
@TableField(exist = false)
private String schoolName;
@TableField(exist = false)
private String gradeName;
@TableField(exist = false)
private String className;
@TableField(exist = false)
private String subjectName;
}

View File

@ -0,0 +1,12 @@
package org.dromara.pangu.base.mapper;
import org.dromara.common.mybatis.core.mapper.BaseMapperPlus;
import org.dromara.pangu.base.domain.PgEducation;
/**
* 教育信息 Mapper 接口
*
* @author 湖北新华业务中台研发团队
*/
public interface PgEducationMapper extends BaseMapperPlus<PgEducation, PgEducation> {
}

View File

@ -14,6 +14,7 @@ public interface IPgRegionService {
List<PgRegion> selectTree();
PgRegion selectById(Long regionId);
List<PgRegion> selectByParentId(Long parentId);
List<Long> getRegionPath(Long regionId);
int insert(PgRegion region);
int update(PgRegion region);
int deleteByIds(Long[] regionIds);

View File

@ -69,6 +69,30 @@ public class PgRegionServiceImpl implements IPgRegionService {
.orderByAsc(PgRegion::getOrderNum));
}
@Override
public List<Long> getRegionPath(Long regionId) {
if (regionId == null) {
return new ArrayList<>();
}
PgRegion region = baseMapper.selectById(regionId);
if (region == null) {
return new ArrayList<>();
}
List<Long> path = new ArrayList<>();
// ancestors 格式: "0,42,4201" 表示从根到父的路径
if (StrUtil.isNotBlank(region.getAncestors())) {
String[] ancestorIds = region.getAncestors().split(",");
for (String id : ancestorIds) {
if (StrUtil.isNotBlank(id) && !"0".equals(id)) {
path.add(Long.parseLong(id));
}
}
}
// 最后加上当前区域ID
path.add(regionId);
return path;
}
@Override
public int insert(PgRegion region) {
return baseMapper.insert(region);

View File

@ -0,0 +1,46 @@
package org.dromara.pangu.h5.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* H5微信登录配置属性
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Component
@ConfigurationProperties(prefix = "h5.wechat")
public class H5WechatProperties {
/**
* 是否启用微信登录
*/
private boolean enabled = false;
/**
* 微信开放平台AppID网站应用
*/
private String appId;
/**
* 微信开放平台AppSecret
*/
private String appSecret;
/**
* 二维码有效期默认5分钟
*/
private int qrcodeExpireSeconds = 300;
/**
* 绑定凭证有效期分钟默认10分钟
*/
private int bindTokenExpireMinutes = 10;
/**
* 首次登录是否自动注册会员
*/
private boolean autoRegister = true;
}

View File

@ -13,12 +13,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.domain.R;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
import org.dromara.pangu.h5.domain.dto.*;
import org.dromara.pangu.h5.domain.vo.*;
import org.dromara.pangu.h5.service.H5AuthService;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@ -230,4 +226,165 @@ public class H5AuthController {
authService.logout();
return R.ok();
}
// ==================== 微信扫码登录接口 ====================
/**
* 获取微信扫码登录二维码
*/
@Operation(
summary = "获取微信扫码登录二维码",
description = """
生成微信扫码登录二维码
**使用流程**
1. 调用本接口获取二维码URL和ticket
2. 前端将qrcodeUrl展示为二维码可使用iframe或img
3. 前端轮询 /wechat/status/{ticket} 查询扫码状态
4. 状态为confirmed时调用 /wechat/login 完成登录
**二维码有效期** 默认5分钟
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = WechatQrcodeVo.class))),
@ApiResponse(responseCode = "500", description = "微信登录功能未启用")
})
@GetMapping("/wechat/qrcode")
public R<WechatQrcodeVo> getWechatQrcode() {
return R.ok(authService.getWechatQrcode());
}
/**
* 查询微信扫码状态
*/
@Operation(
summary = "查询微信扫码状态",
description = """
轮询查询用户扫码状态
**状态说明**
- waiting: 等待扫码
- scanned: 已扫码等待用户确认
- confirmed: 用户已确认可以登录
- expired: 二维码已过期
**轮询建议** 每2秒查询一次
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "查询成功",
content = @Content(schema = @Schema(implementation = WechatStatusVo.class)))
})
@Parameters({
@Parameter(name = "ticket", description = "二维码凭证", required = true, in = ParameterIn.PATH)
})
@GetMapping("/wechat/status/{ticket}")
public R<WechatStatusVo> getWechatStatus(@PathVariable String ticket) {
return R.ok(authService.getWechatStatus(ticket));
}
/**
* 微信扫码登录
*/
@Operation(
summary = "微信扫码登录",
description = """
用户扫码确认后调用此接口完成登录
**返回说明**
- needBindPhone=false: 登录成功返回Token
- needBindPhone=true: 需要绑定手机号返回bindToken
**绑定手机号流程**
1. 获取bindToken
2. 调用 /captcha 获取图形验证码
3. 调用 /wechat/sms/send 发送短信验证码
4. 调用 /wechat/bindPhone 绑定手机号并登录
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "登录成功或需要绑定手机号",
content = @Content(schema = @Schema(implementation = WechatLoginVo.class))),
@ApiResponse(responseCode = "500", description = "登录失败")
})
@PostMapping("/wechat/login")
public R<WechatLoginVo> loginByWechat(@Valid @RequestBody WechatLoginDto dto) {
return R.ok(authService.loginByWechat(dto));
}
/**
* 发送微信绑定手机号短信验证码
*/
@Operation(
summary = "发送微信绑定手机号短信验证码",
description = """
微信首次登录时需要绑定手机号调用此接口发送短信验证码
**使用流程**
1. 先调用 /captcha 获取图形验证码
2. 调用本接口发送短信验证码
3. 调用 /wechat/bindPhone 绑定手机号
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "发送成功"),
@ApiResponse(responseCode = "500", description = "发送失败")
})
@PostMapping("/wechat/sms/send")
public R<Void> sendSmsCodeForWechatBind(@Valid @RequestBody H5SmsSendDto dto) {
authService.sendSmsCodeForWechatBind(dto);
return R.ok();
}
/**
* 微信登录绑定手机号
*/
@Operation(
summary = "微信登录绑定手机号",
description = """
微信首次登录时绑定手机号并完成登录
**说明**
- 如果手机号已存在则将微信绑定到现有账号
- 如果手机号不存在则创建新会员
- 绑定成功后自动登录
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "绑定成功并登录",
content = @Content(schema = @Schema(implementation = WechatLoginVo.class))),
@ApiResponse(responseCode = "500", description = "绑定失败")
})
@PostMapping("/wechat/bindPhone")
public R<WechatLoginVo> bindPhoneForWechat(@Valid @RequestBody WechatBindPhoneDto dto) {
return R.ok(authService.bindPhoneForWechat(dto));
}
/**
* 微信回调处理前端调用
*/
@Operation(
summary = "微信回调处理",
description = """
前端回调页面收到微信code后调用此接口处理
**说明**
此接口由前端微信回调页面调用将微信返回的code和state传给后端处理
处理成功后前端轮询状态接口会得到confirmed状态
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "处理成功")
})
@Parameters({
@Parameter(name = "code", description = "微信授权码", required = true, in = ParameterIn.QUERY),
@Parameter(name = "state", description = "状态参数即ticket", required = true, in = ParameterIn.QUERY)
})
@GetMapping("/wechat/callback")
public R<Void> handleWechatCallback(@RequestParam String code, @RequestParam String state) {
((org.dromara.pangu.h5.service.impl.H5AuthServiceImpl) authService).handleWechatCallback(code, state);
return R.ok();
}
}

View File

@ -122,12 +122,12 @@ public class H5MemberController {
}
/**
* 添加/修改教育身份
* 添加教育身份
*/
@Operation(
summary = "设置教育身份(教师)",
summary = "添加教育身份",
description = """
设置或修改会员的教育身份信息设置后身份类型变为"教师"
添加一个教育身份一个会员可以有多个教育身份每班一条记录
**必填信息**
- 学校
@ -143,31 +143,52 @@ public class H5MemberController {
"""
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "设置成功"),
@ApiResponse(responseCode = "200", description = "添加成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "设置失败,可能原因:学校/年级/班级/学科不存在")
@ApiResponse(responseCode = "500", description = "添加失败,可能原因:学校/年级/班级/学科不存在")
})
@PostMapping("/education")
public R<Void> saveEducation(@Valid @RequestBody H5EducationDto dto) {
memberService.saveEducation(dto);
@PostMapping("/educations")
public R<Void> addEducation(@Valid @RequestBody H5EducationDto dto) {
memberService.addEducation(dto);
return R.ok();
}
/**
* 获取教育身份
* 修改教育身份
*/
@Operation(
summary = "获取教育身份信息",
description = "获取当前会员的教育身份信息学校、年级、班级、学科。如果未设置教育身份返回null。"
summary = "修改教育身份",
description = "修改指定的教育身份信息。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "修改成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "修改失败,可能原因:教育身份不存在或无权限")
})
@Parameters({
@Parameter(name = "educationId", description = "教育身份ID", required = true, in = ParameterIn.PATH)
})
@PutMapping("/educations/{educationId}")
public R<Void> updateEducation(@PathVariable Long educationId, @Valid @RequestBody H5EducationDto dto) {
memberService.updateEducation(educationId, dto);
return R.ok();
}
/**
* 获取教育身份列表
*/
@Operation(
summary = "获取教育身份列表",
description = "获取当前会员的所有教育身份信息(学校、年级、班级、学科)。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "获取成功",
content = @Content(schema = @Schema(implementation = H5EducationVo.class))),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期")
})
@GetMapping("/education")
public R<H5EducationVo> getEducation() {
return R.ok(memberService.getEducation());
@GetMapping("/educations")
public R<List<H5EducationVo>> getEducations() {
return R.ok(memberService.getEducations());
}
/**
@ -175,15 +196,40 @@ public class H5MemberController {
*/
@Operation(
summary = "删除教育身份",
description = "清除当前会员的教育身份信息,身份类型将变为空"
description = "删除指定的教育身份"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "删除成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期")
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "删除失败,可能原因:教育身份不存在或无权限")
})
@DeleteMapping("/education")
public R<Void> deleteEducation() {
memberService.deleteEducation();
@Parameters({
@Parameter(name = "educationId", description = "教育身份ID", required = true, in = ParameterIn.PATH)
})
@DeleteMapping("/educations/{educationId}")
public R<Void> deleteEducation(@PathVariable Long educationId) {
memberService.deleteEducation(educationId);
return R.ok();
}
/**
* 设置默认教育身份
*/
@Operation(
summary = "设置默认教育身份",
description = "将指定教育身份设为默认。"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "设置成功"),
@ApiResponse(responseCode = "401", description = "未登录或Token已过期"),
@ApiResponse(responseCode = "500", description = "设置失败,可能原因:教育身份不存在或无权限")
})
@Parameters({
@Parameter(name = "educationId", description = "教育身份ID", required = true, in = ParameterIn.PATH)
})
@PutMapping("/educations/{educationId}/default")
public R<Void> setDefaultEducation(@PathVariable Long educationId) {
memberService.setDefaultEducation(educationId);
return R.ok();
}

View File

@ -13,6 +13,9 @@ import lombok.Data;
@Schema(description = "教育身份设置请求参数(教师身份)")
public class H5EducationDto {
@Schema(description = "教育身份ID新增时不传编辑时必传", example = "1")
private Long educationId;
@Schema(description = "学校ID从/h5/base/schools获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "请选择学校")
private Long schoolId;

View File

@ -0,0 +1,37 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
/**
* 微信登录绑定手机号请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Schema(description = "微信登录绑定手机号请求参数")
public class WechatBindPhoneDto {
@Schema(description = "绑定凭证从微信登录接口获取needBindPhone=true时返回", requiredMode = Schema.RequiredMode.REQUIRED, example = "bind_abc123")
@NotBlank(message = "绑定凭证不能为空")
private String bindToken;
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13812345678")
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Schema(description = "短信验证码6位数字", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456")
@NotBlank(message = "短信验证码不能为空")
private String smsCode;
@Schema(description = "图形验证码答案", requiredMode = Schema.RequiredMode.REQUIRED, example = "5")
@NotBlank(message = "图形验证码不能为空")
private String captchaCode;
@Schema(description = "图形验证码标识(从/captcha接口获取", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
@NotBlank(message = "验证码标识不能为空")
private String uuid;
}

View File

@ -0,0 +1,23 @@
package org.dromara.pangu.h5.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 微信扫码登录请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Schema(description = "微信扫码登录请求参数")
public class WechatLoginDto {
@Schema(description = "二维码凭证(从获取二维码接口获取)", requiredMode = Schema.RequiredMode.REQUIRED, example = "abc123def456")
@NotBlank(message = "二维码凭证不能为空")
private String ticket;
@Schema(description = "临时登录码从查询状态接口获取状态为confirmed时返回", requiredMode = Schema.RequiredMode.REQUIRED, example = "xyz789abc")
@NotBlank(message = "临时登录码不能为空")
private String tempCode;
}

View File

@ -12,6 +12,9 @@ import lombok.Data;
@Schema(description = "教育身份信息(教师)")
public class H5EducationVo {
@Schema(description = "教育身份ID", example = "1")
private Long educationId;
@Schema(description = "省份ID", example = "420000")
private Long provinceId;
@ -53,4 +56,7 @@ public class H5EducationVo {
@Schema(description = "学科名称", example = "语文")
private String subjectName;
@Schema(description = "是否默认身份0否 1是", example = "1")
private String isDefault;
}

View File

@ -32,7 +32,4 @@ public class H5LoginVo {
@Schema(description = "昵称", example = "user_5678")
private String nickname;
@Schema(description = "身份类型1-家长2-教师null-未设置", example = "1")
private String identityType;
}

View File

@ -39,11 +39,8 @@ public class H5MemberInfoVo {
@Schema(description = "注册时间", example = "2024-01-01 12:00:00")
private Date registerTime;
@Schema(description = "身份类型1-家长2-教师", example = "2")
private String identityType;
@Schema(description = "教育身份信息(仅教师身份有值)")
private H5EducationVo education;
@Schema(description = "教育身份列表")
private List<H5EducationVo> educations;
@Schema(description = "绑定的学生列表")
private List<H5StudentVo> students;

View File

@ -0,0 +1,45 @@
package org.dromara.pangu.h5.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 微信登录响应VO
* 登录成功时返回Token需要绑定手机号时返回bindToken
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Schema(description = "微信登录响应")
public class WechatLoginVo {
@Schema(description = "是否需要绑定手机号", example = "false")
private Boolean needBindPhone;
@Schema(description = "绑定凭证needBindPhone=true时返回用于绑定手机号接口", example = "bind_abc123")
private String bindToken;
@Schema(description = "访问令牌needBindPhone=false时返回", example = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...")
private String accessToken;
@Schema(description = "刷新令牌needBindPhone=false时返回", example = "refresh_abc123def456")
private String refreshToken;
@Schema(description = "accessToken过期时间", example = "7200")
private Long expiresIn;
@Schema(description = "会员IDneedBindPhone=false时返回", example = "1234567890123456789")
private Long memberId;
@Schema(description = "会员编号needBindPhone=false时返回", example = "M170900000012345678")
private String memberCode;
@Schema(description = "手机号脱敏needBindPhone=false时返回", example = "138****5678")
private String phone;
@Schema(description = "昵称needBindPhone=false时返回", example = "user_5678")
private String nickname;
@Schema(description = "身份类型needBindPhone=false时返回", example = "1")
private String identityType;
}

View File

@ -0,0 +1,23 @@
package org.dromara.pangu.h5.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 微信扫码登录二维码响应VO
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Schema(description = "微信扫码登录二维码响应")
public class WechatQrcodeVo {
@Schema(description = "二维码凭证,用于后续查询扫码状态", example = "abc123def456")
private String ticket;
@Schema(description = "二维码图片URL", example = "https://open.weixin.qq.com/connect/qrcode/xxx")
private String qrcodeUrl;
@Schema(description = "二维码有效期(秒)", example = "300")
private Integer expireSeconds;
}

View File

@ -0,0 +1,20 @@
package org.dromara.pangu.h5.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 微信扫码状态响应VO
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Schema(description = "微信扫码状态响应")
public class WechatStatusVo {
@Schema(description = "扫码状态waiting-等待扫码scanned-已扫码待确认confirmed-已确认expired-已过期", example = "waiting")
private String status;
@Schema(description = "临时登录码状态为confirmed时返回用于后续登录接口", example = "xyz789abc")
private String tempCode;
}

View File

@ -1,11 +1,7 @@
package org.dromara.pangu.h5.service;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
import org.dromara.pangu.h5.domain.dto.*;
import org.dromara.pangu.h5.domain.vo.*;
/**
* H5认证服务接口
@ -48,4 +44,33 @@ public interface H5AuthService {
* 退出登录
*/
void logout();
// ==================== 微信扫码登录 ====================
/**
* 获取微信扫码登录二维码
*/
WechatQrcodeVo getWechatQrcode();
/**
* 查询微信扫码状态
*
* @param ticket 二维码凭证
*/
WechatStatusVo getWechatStatus(String ticket);
/**
* 微信扫码登录
*/
WechatLoginVo loginByWechat(WechatLoginDto dto);
/**
* 微信登录绑定手机号
*/
WechatLoginVo bindPhoneForWechat(WechatBindPhoneDto dto);
/**
* 发送微信绑定手机号的短信验证码
*/
void sendSmsCodeForWechatBind(H5SmsSendDto dto);
}

View File

@ -33,19 +33,29 @@ public interface H5MemberService {
void updatePassword(H5PasswordUpdateDto dto);
/**
* 添加/修改教育身份
* 添加教育身份
*/
void saveEducation(H5EducationDto dto);
void addEducation(H5EducationDto dto);
/**
* 获取教育身份
* 修改教育身份
*/
H5EducationVo getEducation();
void updateEducation(Long educationId, H5EducationDto dto);
/**
* 获取教育身份列表
*/
List<H5EducationVo> getEducations();
/**
* 删除教育身份
*/
void deleteEducation();
void deleteEducation(Long educationId);
/**
* 设置默认教育身份
*/
void setDefaultEducation(Long educationId);
/**
* 绑定学生

View File

@ -6,6 +6,9 @@ import cn.hutool.captcha.generator.MathGenerator;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -17,12 +20,9 @@ import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.redis.utils.RedisUtils;
import org.dromara.common.web.core.WaveAndCircleCaptcha;
import org.dromara.pangu.h5.config.H5SmsProperties;
import org.dromara.pangu.h5.domain.dto.H5PasswordLoginDto;
import org.dromara.pangu.h5.domain.dto.H5RegisterDto;
import org.dromara.pangu.h5.domain.dto.H5SmsLoginDto;
import org.dromara.pangu.h5.domain.dto.H5SmsSendDto;
import org.dromara.pangu.h5.domain.vo.H5CaptchaVo;
import org.dromara.pangu.h5.domain.vo.H5LoginVo;
import org.dromara.pangu.h5.config.H5WechatProperties;
import org.dromara.pangu.h5.domain.dto.*;
import org.dromara.pangu.h5.domain.vo.*;
import org.dromara.pangu.h5.service.H5AuthService;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.mapper.PgMemberMapper;
@ -35,6 +35,8 @@ import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Service;
import java.awt.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@ -53,6 +55,7 @@ public class H5AuthServiceImpl implements H5AuthService {
private final PgMemberMapper memberMapper;
private final H5SmsProperties smsProperties;
private final H5WechatProperties wechatProperties;
/**
* H5会员登录设备标识
@ -70,6 +73,12 @@ public class H5AuthServiceImpl implements H5AuthService {
private static final String H5_SMS_FAIL_COUNT_KEY = "h5:sms:fail:";
private static final String H5_REFRESH_TOKEN_KEY = "h5:refresh:token:";
/**
* 微信扫码登录相关Redis Key前缀
*/
private static final String H5_WECHAT_QRCODE_KEY = "h5:wechat:qrcode:";
private static final String H5_WECHAT_BIND_KEY = "h5:wechat:bind:";
/**
* accessToken有效期- 2小时
*/
@ -400,7 +409,6 @@ public class H5AuthServiceImpl implements H5AuthService {
vo.setMemberCode(member.getMemberCode());
vo.setPhone(maskPhone(member.getPhone()));
vo.setNickname(member.getNickname());
vo.setIdentityType(member.getIdentityType());
return vo;
}
@ -524,4 +532,428 @@ public class H5AuthServiceImpl implements H5AuthService {
}
return phone.substring(0, 3) + "****" + phone.substring(7);
}
// ==================== 微信扫码登录实现 ====================
@Override
public WechatQrcodeVo getWechatQrcode() {
if (!wechatProperties.isEnabled()) {
throw new ServiceException("微信登录功能未启用");
}
// 生成唯一ticket
String ticket = IdUtil.fastSimpleUUID();
int expireSeconds = wechatProperties.getQrcodeExpireSeconds();
// 构建微信扫码登录URL
// 使用微信开放平台的网站应用扫码登录
String state = ticket;
String redirectUri = URLEncoder.encode(
getWechatCallbackUrl(),
StandardCharsets.UTF_8
);
String qrcodeUrl = String.format(
"https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_login&state=%s#wechat_redirect",
wechatProperties.getAppId(),
redirectUri,
state
);
// 存储ticket状态到Redis
// 初始状态waiting
JSONObject qrcodeData = new JSONObject();
qrcodeData.set("status", "waiting");
qrcodeData.set("createTime", System.currentTimeMillis());
RedisUtils.setCacheObject(
H5_WECHAT_QRCODE_KEY + ticket,
qrcodeData.toString(),
Duration.ofSeconds(expireSeconds)
);
WechatQrcodeVo vo = new WechatQrcodeVo();
vo.setTicket(ticket);
vo.setQrcodeUrl(qrcodeUrl);
vo.setExpireSeconds(expireSeconds);
log.info("生成微信扫码登录二维码: ticket={}", ticket);
return vo;
}
@Override
public WechatStatusVo getWechatStatus(String ticket) {
if (!wechatProperties.isEnabled()) {
throw new ServiceException("微信登录功能未启用");
}
if (StringUtils.isBlank(ticket)) {
throw new ServiceException("ticket不能为空");
}
String cacheData = RedisUtils.getCacheObject(H5_WECHAT_QRCODE_KEY + ticket);
if (StringUtils.isBlank(cacheData)) {
// ticket不存在或已过期
WechatStatusVo vo = new WechatStatusVo();
vo.setStatus("expired");
return vo;
}
JSONObject data = JSONUtil.parseObj(cacheData);
String status = data.getStr("status");
WechatStatusVo vo = new WechatStatusVo();
vo.setStatus(status);
// 如果已确认返回tempCode
if ("confirmed".equals(status)) {
vo.setTempCode(data.getStr("tempCode"));
}
return vo;
}
@Override
public WechatLoginVo loginByWechat(WechatLoginDto dto) {
if (!wechatProperties.isEnabled()) {
throw new ServiceException("微信登录功能未启用");
}
String ticket = dto.getTicket();
String tempCode = dto.getTempCode();
// 验证ticket
String cacheData = RedisUtils.getCacheObject(H5_WECHAT_QRCODE_KEY + ticket);
if (StringUtils.isBlank(cacheData)) {
throw new ServiceException("二维码已过期,请刷新重试");
}
JSONObject data = JSONUtil.parseObj(cacheData);
String status = data.getStr("status");
if (!"confirmed".equals(status)) {
throw new ServiceException("请先完成扫码确认");
}
// 验证tempCode
String cachedTempCode = data.getStr("tempCode");
if (!tempCode.equals(cachedTempCode)) {
throw new ServiceException("登录凭证无效");
}
String openId = data.getStr("openId");
String unionId = data.getStr("unionId");
String nickname = data.getStr("nickname");
String headImgUrl = data.getStr("headImgUrl");
// 删除ticket缓存
RedisUtils.deleteObject(H5_WECHAT_QRCODE_KEY + ticket);
// 根据openId查询会员
PgMember member = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>().eq(PgMember::getOpenId, openId)
);
WechatLoginVo vo = new WechatLoginVo();
if (member != null) {
// 已绑定直接登录
if ("1".equals(member.getStatus())) {
throw new ServiceException("账号已被禁用");
}
H5LoginVo loginVo = doLogin(member, false);
vo.setNeedBindPhone(false);
vo.setAccessToken(loginVo.getAccessToken());
vo.setRefreshToken(loginVo.getRefreshToken());
vo.setExpiresIn(loginVo.getExpiresIn());
vo.setMemberId(loginVo.getMemberId());
vo.setMemberCode(loginVo.getMemberCode());
vo.setPhone(loginVo.getPhone());
vo.setNickname(loginVo.getNickname());
log.info("微信登录成功: openId={}, memberId={}", openId, member.getMemberId());
} else {
// 未绑定需要绑定手机号
String bindToken = IdUtil.fastSimpleUUID();
// 存储绑定信息
JSONObject bindData = new JSONObject();
bindData.set("openId", openId);
bindData.set("unionId", unionId);
bindData.set("nickname", nickname);
bindData.set("headImgUrl", headImgUrl);
RedisUtils.setCacheObject(
H5_WECHAT_BIND_KEY + bindToken,
bindData.toString(),
Duration.ofMinutes(wechatProperties.getBindTokenExpireMinutes())
);
vo.setNeedBindPhone(true);
vo.setBindToken(bindToken);
log.info("微信登录需要绑定手机号: openId={}, bindToken={}", openId, bindToken);
}
return vo;
}
@Override
public WechatLoginVo bindPhoneForWechat(WechatBindPhoneDto dto) {
if (!wechatProperties.isEnabled()) {
throw new ServiceException("微信登录功能未启用");
}
// 校验图形验证码
validateCaptcha(dto.getUuid(), dto.getCaptchaCode());
// 校验短信验证码
validateSmsCode(dto.getPhone(), dto.getSmsCode(), "wechat_bind");
// 获取绑定信息
String bindData = RedisUtils.getCacheObject(H5_WECHAT_BIND_KEY + dto.getBindToken());
if (StringUtils.isBlank(bindData)) {
throw new ServiceException("绑定凭证已过期,请重新扫码");
}
JSONObject data = JSONUtil.parseObj(bindData);
String openId = data.getStr("openId");
String unionId = data.getStr("unionId");
String nickname = data.getStr("nickname");
// 删除绑定凭证
RedisUtils.deleteObject(H5_WECHAT_BIND_KEY + dto.getBindToken());
// 检查openId是否已被绑定
PgMember existByOpenId = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>().eq(PgMember::getOpenId, openId)
);
if (existByOpenId != null) {
throw new ServiceException("该微信已绑定其他账号");
}
// 检查手机号是否已存在
PgMember existByPhone = memberMapper.selectOne(
new LambdaQueryWrapper<PgMember>().eq(PgMember::getPhone, dto.getPhone())
);
PgMember member;
if (existByPhone != null) {
// 手机号已存在绑定微信到现有账号
if ("1".equals(existByPhone.getStatus())) {
throw new ServiceException("该手机号账号已被禁用");
}
existByPhone.setOpenId(openId);
existByPhone.setUnionId(unionId);
memberMapper.updateById(existByPhone);
member = existByPhone;
log.info("微信绑定到现有账号: phone={}, openId={}, memberId={}", dto.getPhone(), openId, member.getMemberId());
} else {
// 手机号不存在创建新会员
member = new PgMember();
member.setPhone(dto.getPhone());
member.setOpenId(openId);
member.setUnionId(unionId);
member.setMemberCode(generateMemberCode());
member.setNickname(StringUtils.isNotBlank(nickname) ? nickname : "user_" + dto.getPhone().substring(7));
member.setRegisterSource("2"); // H5注册
member.setRegisterTime(new Date());
member.setStatus("0");
member.setLoginCount(0);
memberMapper.insert(member);
log.info("微信登录创建新会员: phone={}, openId={}, memberId={}", dto.getPhone(), openId, member.getMemberId());
}
// 执行登录
H5LoginVo loginVo = doLogin(member, false);
WechatLoginVo vo = new WechatLoginVo();
vo.setNeedBindPhone(false);
vo.setAccessToken(loginVo.getAccessToken());
vo.setRefreshToken(loginVo.getRefreshToken());
vo.setExpiresIn(loginVo.getExpiresIn());
vo.setMemberId(loginVo.getMemberId());
vo.setMemberCode(loginVo.getMemberCode());
vo.setPhone(loginVo.getPhone());
vo.setNickname(loginVo.getNickname());
return vo;
}
@Override
public void sendSmsCodeForWechatBind(H5SmsSendDto dto) {
// 校验图形验证码
validateCaptcha(dto.getUuid(), dto.getCaptchaCode());
String phone = dto.getPhone();
String clientIp = getClientIp();
// ========== 防刷检查 ==========
if (isPhoneBlacklisted(phone)) {
throw new ServiceException("操作过于频繁,请" + smsProperties.getBlacklistMinutes() + "分钟后再试");
}
if (isIpBlacklisted(clientIp)) {
throw new ServiceException("操作过于频繁,请稍后再试");
}
String codeKey = H5_SMS_CODE_KEY + "wechat_bind:" + phone;
if (RedisUtils.hasKey(codeKey)) {
Long ttl = RedisUtils.getTimeToLive(codeKey);
long intervalCheck = (smsProperties.getCodeExpireMinutes() * 60L) - smsProperties.getSendIntervalSeconds();
if (ttl != null && ttl > intervalCheck) {
throw new ServiceException("验证码发送过于频繁,请" + smsProperties.getSendIntervalSeconds() + "秒后再试");
}
}
String dailyPhoneKey = H5_SMS_DAILY_PHONE_KEY + getTodayStr() + ":" + phone;
Integer phoneCount = RedisUtils.getCacheObject(dailyPhoneKey);
if (phoneCount != null && phoneCount >= smsProperties.getDailyLimitPerPhone()) {
throw new ServiceException("今日发送次数已达上限,请明天再试");
}
// ========== 发送短信 ==========
String code = RandomUtil.randomNumbers(smsProperties.getCodeLength());
RedisUtils.setCacheObject(codeKey, code, Duration.ofMinutes(smsProperties.getCodeExpireMinutes()));
incrementCounter(dailyPhoneKey, Duration.ofDays(1));
if (smsProperties.isEnabled()) {
try {
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend(smsProperties.getSmsConfigName());
String templateId = smsProperties.getLoginTemplateId();
SmsResponse smsResponse;
if (StringUtils.isNotBlank(templateId)) {
smsResponse = smsBlend.sendMessage(phone, templateId, map);
} else {
smsResponse = smsBlend.sendMessage(phone, map);
}
if (!smsResponse.isSuccess()) {
log.error("短信发送失败: phone={}, response={}", phone, smsResponse);
throw new ServiceException("短信发送失败,请稍后重试");
}
log.info("微信绑定短信发送成功: phone={}, ip={}", phone, clientIp);
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("短信发送异常: phone={}, error={}", phone, e.getMessage());
throw new ServiceException("短信发送失败,请稍后重试");
}
} else {
log.info("【测试模式】微信绑定短信验证码: phone={}, code={}", phone, code);
}
}
/**
* 获取微信回调URL由前端页面处理回调
*/
private String getWechatCallbackUrl() {
// 返回前端回调页面URL前端需要实现该页面处理微信回调
// 实际使用时需要配置为真实的前端回调地址
return "https://your-domain.com/wechat-callback";
}
/**
* 微信回调处理由前端调用传入code和state
* 这个方法用于处理微信回调后前端将code传给后端换取用户信息
*/
public void handleWechatCallback(String code, String state) {
if (!wechatProperties.isEnabled()) {
throw new ServiceException("微信登录功能未启用");
}
String ticket = state;
// 验证ticket是否存在
String cacheData = RedisUtils.getCacheObject(H5_WECHAT_QRCODE_KEY + ticket);
if (StringUtils.isBlank(cacheData)) {
log.warn("微信回调ticket已过期: ticket={}", ticket);
return;
}
try {
// 用code换取access_token和openid
String tokenUrl = String.format(
"https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
wechatProperties.getAppId(),
wechatProperties.getAppSecret(),
code
);
String tokenResult = HttpUtil.get(tokenUrl);
JSONObject tokenJson = JSONUtil.parseObj(tokenResult);
if (tokenJson.containsKey("errcode")) {
log.error("微信获取access_token失败: {}", tokenResult);
return;
}
String accessToken = tokenJson.getStr("access_token");
String openId = tokenJson.getStr("openid");
String unionId = tokenJson.getStr("unionid");
// 获取用户信息
String userInfoUrl = String.format(
"https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s",
accessToken,
openId
);
String userInfoResult = HttpUtil.get(userInfoUrl);
JSONObject userInfo = JSONUtil.parseObj(userInfoResult);
String nickname = userInfo.getStr("nickname");
String headImgUrl = userInfo.getStr("headimgurl");
// 生成tempCode
String tempCode = IdUtil.fastSimpleUUID();
// 更新ticket状态为confirmed
JSONObject data = new JSONObject();
data.set("status", "confirmed");
data.set("tempCode", tempCode);
data.set("openId", openId);
data.set("unionId", unionId);
data.set("nickname", nickname);
data.set("headImgUrl", headImgUrl);
// 获取剩余过期时间
Long ttl = RedisUtils.getTimeToLive(H5_WECHAT_QRCODE_KEY + ticket);
if (ttl != null && ttl > 0) {
RedisUtils.setCacheObject(
H5_WECHAT_QRCODE_KEY + ticket,
data.toString(),
Duration.ofSeconds(ttl)
);
}
log.info("微信扫码回调成功: ticket={}, openId={}", ticket, openId);
} catch (Exception e) {
log.error("微信回调处理异常: ticket={}, error={}", ticket, e.getMessage(), e);
}
}
/**
* 更新扫码状态为已扫码由前端轮询时模拟实际由微信事件推送
*/
public void updateWechatScanStatus(String ticket) {
String cacheData = RedisUtils.getCacheObject(H5_WECHAT_QRCODE_KEY + ticket);
if (StringUtils.isBlank(cacheData)) {
return;
}
JSONObject data = JSONUtil.parseObj(cacheData);
if ("waiting".equals(data.getStr("status"))) {
data.set("status", "scanned");
Long ttl = RedisUtils.getTimeToLive(H5_WECHAT_QRCODE_KEY + ticket);
if (ttl != null && ttl > 0) {
RedisUtils.setCacheObject(
H5_WECHAT_QRCODE_KEY + ticket,
data.toString(),
Duration.ofSeconds(ttl)
);
}
}
}
}

View File

@ -9,10 +9,12 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.pangu.base.domain.PgClass;
import org.dromara.pangu.base.domain.PgEducation;
import org.dromara.pangu.base.domain.PgGrade;
import org.dromara.pangu.base.domain.PgRegion;
import org.dromara.pangu.base.domain.PgSubject;
import org.dromara.pangu.base.mapper.PgClassMapper;
import org.dromara.pangu.base.mapper.PgEducationMapper;
import org.dromara.pangu.base.mapper.PgGradeMapper;
import org.dromara.pangu.base.mapper.PgRegionMapper;
import org.dromara.pangu.base.mapper.PgSubjectMapper;
@ -59,6 +61,7 @@ public class H5MemberServiceImpl implements H5MemberService {
private final PgClassMapper classMapper;
private final PgSubjectMapper subjectMapper;
private final PgRegionMapper regionMapper;
private final PgEducationMapper educationMapper;
@Override
public H5MemberInfoVo getMemberInfo() {
@ -77,12 +80,9 @@ public class H5MemberServiceImpl implements H5MemberService {
vo.setGender(member.getGender());
vo.setBirthday(member.getBirthday());
vo.setRegisterTime(member.getRegisterTime());
vo.setIdentityType(member.getIdentityType());
// 获取教育身份信息
if ("2".equals(member.getIdentityType()) && member.getSchoolId() != null) {
vo.setEducation(buildEducationVo(member));
}
// 获取教育身份列表
vo.setEducations(getEducations());
// 获取绑定的学生列表
vo.setStudents(getStudents());
@ -139,14 +139,125 @@ public class H5MemberServiceImpl implements H5MemberService {
@Override
@Transactional(rollbackFor = Exception.class)
public void saveEducation(H5EducationDto dto) {
public void addEducation(H5EducationDto dto) {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
// 校验学校年级班级学科是否存在
PgSchool school = validateAndGetSchool(dto);
// 创建教育身份记录
PgEducation education = new PgEducation();
education.setMemberId(memberId);
education.setRegionId(school.getRegionId());
education.setSchoolId(dto.getSchoolId());
education.setSchoolGradeId(dto.getSchoolGradeId());
education.setSchoolClassId(dto.getSchoolClassId());
education.setSubjectId(dto.getSubjectId());
education.setStatus("0");
// 如果是第一个教育身份设为默认
long count = educationMapper.selectCount(
new LambdaQueryWrapper<PgEducation>()
.eq(PgEducation::getMemberId, memberId)
.eq(PgEducation::getDelFlag, "0")
);
education.setIsDefault(count == 0 ? "1" : "0");
educationMapper.insert(education);
log.info("H5添加教育身份: memberId={}, educationId={}", memberId, education.getEducationId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateEducation(Long educationId, H5EducationDto dto) {
Long memberId = getCurrentMemberId();
// 查询并校验教育身份归属
PgEducation education = educationMapper.selectById(educationId);
if (education == null || !memberId.equals(education.getMemberId())) {
throw new ServiceException("教育身份不存在或无权限修改");
}
// 校验学校年级班级学科是否存在
PgSchool school = validateAndGetSchool(dto);
// 更新教育身份
education.setRegionId(school.getRegionId());
education.setSchoolId(dto.getSchoolId());
education.setSchoolGradeId(dto.getSchoolGradeId());
education.setSchoolClassId(dto.getSchoolClassId());
education.setSubjectId(dto.getSubjectId());
educationMapper.updateById(education);
log.info("H5修改教育身份: memberId={}, educationId={}", memberId, educationId);
}
@Override
public List<H5EducationVo> getEducations() {
Long memberId = getCurrentMemberId();
List<PgEducation> educations = educationMapper.selectList(
new LambdaQueryWrapper<PgEducation>()
.eq(PgEducation::getMemberId, memberId)
.eq(PgEducation::getDelFlag, "0")
.orderByDesc(PgEducation::getIsDefault)
.orderByDesc(PgEducation::getCreateTime)
);
List<H5EducationVo> voList = new ArrayList<>();
for (PgEducation education : educations) {
voList.add(buildEducationVo(education));
}
return voList;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteEducation(Long educationId) {
Long memberId = getCurrentMemberId();
// 查询并校验教育身份归属
PgEducation education = educationMapper.selectById(educationId);
if (education == null || !memberId.equals(education.getMemberId())) {
throw new ServiceException("教育身份不存在或无权限删除");
}
// 逻辑删除
educationMapper.deleteById(educationId);
log.info("H5删除教育身份: memberId={}, educationId={}", memberId, educationId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void setDefaultEducation(Long educationId) {
Long memberId = getCurrentMemberId();
// 查询并校验教育身份归属
PgEducation education = educationMapper.selectById(educationId);
if (education == null || !memberId.equals(education.getMemberId())) {
throw new ServiceException("教育身份不存在或无权限操作");
}
// 取消其他默认
PgEducation updateEntity = new PgEducation();
updateEntity.setIsDefault("0");
educationMapper.update(updateEntity,
new LambdaQueryWrapper<PgEducation>()
.eq(PgEducation::getMemberId, memberId)
.eq(PgEducation::getIsDefault, "1")
);
// 设置当前为默认
education.setIsDefault("1");
educationMapper.updateById(education);
log.info("H5设置默认教育身份: memberId={}, educationId={}", memberId, educationId);
}
/**
* 校验并获取学校信息
*/
private PgSchool validateAndGetSchool(H5EducationDto dto) {
PgSchool school = schoolMapper.selectById(dto.getSchoolId());
if (school == null) {
throw new ServiceException("学校不存在");
@ -167,68 +278,13 @@ public class H5MemberServiceImpl implements H5MemberService {
throw new ServiceException("学科不存在");
}
// 更新会员教育信息
member.setIdentityType("2"); // 教师
member.setSchoolId(dto.getSchoolId());
member.setSchoolGradeId(dto.getSchoolGradeId());
member.setSchoolClassId(dto.getSchoolClassId());
// 从学校获取区域信息并保存
if (school.getRegionId() != null) {
member.setRegionId(school.getRegionId());
}
// 学科信息需要在member表添加字段这里先用remark暂存
member.setRemark("subjectId:" + dto.getSubjectId());
memberMapper.updateById(member);
log.info("H5教育身份保存: memberId={}, schoolId={}", memberId, dto.getSchoolId());
}
@Override
public H5EducationVo getEducation() {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
if (!"2".equals(member.getIdentityType()) || member.getSchoolId() == null) {
return null;
}
H5EducationVo vo = buildEducationVo(member);
log.info("H5获取教育身份: memberId={}, provinceId={}, cityId={}, districtId={}, schoolId={}",
memberId, vo.getProvinceId(), vo.getCityId(), vo.getDistrictId(), vo.getSchoolId());
return vo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteEducation() {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
// 清除教育信息
member.setIdentityType(null);
member.setSchoolId(null);
member.setSchoolGradeId(null);
member.setSchoolClassId(null);
member.setRemark(null);
memberMapper.updateById(member);
log.info("H5教育身份删除: memberId={}", memberId);
return school;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void bindStudent(H5StudentBindDto dto) {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
// 校验学校年级班级是否存在
validateSchoolInfo(dto.getSchoolId(), dto.getSchoolGradeId(), dto.getSchoolClassId());
@ -247,12 +303,6 @@ public class H5MemberServiceImpl implements H5MemberService {
student.setStatus("0");
studentMapper.insert(student);
// 如果会员没有身份默认设为家长
if (StringUtils.isBlank(member.getIdentityType())) {
member.setIdentityType("1"); // 家长
memberMapper.updateById(member);
}
log.info("H5绑定学生: memberId={}, studentId={}", memberId, student.getStudentId());
}
@ -348,14 +398,17 @@ public class H5MemberServiceImpl implements H5MemberService {
/**
* 构建教育身份VO
*/
private H5EducationVo buildEducationVo(PgMember member) {
private H5EducationVo buildEducationVo(PgEducation education) {
H5EducationVo vo = new H5EducationVo();
vo.setSchoolId(member.getSchoolId());
vo.setSchoolGradeId(member.getSchoolGradeId());
vo.setSchoolClassId(member.getSchoolClassId());
vo.setEducationId(education.getEducationId());
vo.setSchoolId(education.getSchoolId());
vo.setSchoolGradeId(education.getSchoolGradeId());
vo.setSchoolClassId(education.getSchoolClassId());
vo.setSubjectId(education.getSubjectId());
vo.setIsDefault(education.getIsDefault());
// 获取学校名称和区域信息
PgSchool school = schoolMapper.selectById(member.getSchoolId());
PgSchool school = schoolMapper.selectById(education.getSchoolId());
if (school != null) {
vo.setSchoolName(school.getSchoolName());
@ -366,12 +419,9 @@ public class H5MemberServiceImpl implements H5MemberService {
vo.setDistrictId(district.getRegionId());
vo.setDistrictName(district.getRegionName());
// 根据ancestors获取省市信息格式如 "0,42,4201"
// 根据ancestors获取省市信息
if (StringUtils.isNotBlank(district.getAncestors())) {
String[] ancestorIds = district.getAncestors().split(",");
// ancestorIds[0] = "0" (根节点)
// ancestorIds[1] = 省份ID
// ancestorIds[2] = 城市ID
if (ancestorIds.length >= 2 && !"0".equals(ancestorIds[1])) {
try {
Long provinceId = Long.parseLong(ancestorIds[1].trim());
@ -402,7 +452,7 @@ public class H5MemberServiceImpl implements H5MemberService {
}
// 获取年级名称
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(member.getSchoolGradeId());
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(education.getSchoolGradeId());
if (schoolGrade != null && schoolGrade.getGradeId() != null) {
PgGrade grade = gradeMapper.selectById(schoolGrade.getGradeId());
if (grade != null) {
@ -411,7 +461,7 @@ public class H5MemberServiceImpl implements H5MemberService {
}
// 获取班级名称
PgSchoolClass schoolClass = schoolClassMapper.selectById(member.getSchoolClassId());
PgSchoolClass schoolClass = schoolClassMapper.selectById(education.getSchoolClassId());
if (schoolClass != null && schoolClass.getClassId() != null) {
PgClass cls = classMapper.selectById(schoolClass.getClassId());
if (cls != null) {
@ -419,17 +469,11 @@ public class H5MemberServiceImpl implements H5MemberService {
}
}
// 获取学科信息从remark解析
if (StringUtils.isNotBlank(member.getRemark()) && member.getRemark().startsWith("subjectId:")) {
try {
Long subjectId = Long.parseLong(member.getRemark().replace("subjectId:", ""));
vo.setSubjectId(subjectId);
PgSubject subject = subjectMapper.selectById(subjectId);
if (subject != null) {
vo.setSubjectName(subject.getSubjectName());
}
} catch (Exception e) {
// 忽略解析错误
// 获取学科名称
if (education.getSubjectId() != null) {
PgSubject subject = subjectMapper.selectById(education.getSubjectId());
if (subject != null) {
vo.setSubjectName(subject.getSubjectName());
}
}

View File

@ -9,7 +9,11 @@ import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.common.web.core.BaseController;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.domain.dto.EducationDto;
import org.dromara.pangu.member.domain.dto.MemberSaveDto;
import org.dromara.pangu.member.domain.vo.EducationVo;
import org.dromara.pangu.member.service.IPgMemberService;
import org.dromara.pangu.member.service.IPgEducationService;
import org.dromara.pangu.student.domain.vo.StudentVo;
import org.dromara.pangu.student.service.IPgStudentService;
import org.springframework.validation.annotation.Validated;
@ -32,6 +36,7 @@ public class PgMemberController extends BaseController {
private final IPgMemberService memberService;
private final IPgStudentService studentService;
private final IPgEducationService educationService;
/**
* 查询会员列表
@ -52,23 +57,25 @@ public class PgMemberController extends BaseController {
}
/**
* 新增会员
* 新增会员支持同时保存教育身份
*/
@SaCheckPermission("business:member:add")
@Log(title = "会员管理", businessType = BusinessType.INSERT)
@PostMapping
public R<Void> add(@Validated @RequestBody PgMember member) {
return toAjax(memberService.insert(member));
public R<Void> add(@Validated @RequestBody MemberSaveDto dto) {
memberService.insertWithEducations(dto);
return R.ok();
}
/**
* 修改会员
* 修改会员支持同时保存教育身份
*/
@SaCheckPermission("business:member:edit")
@Log(title = "会员管理", businessType = BusinessType.UPDATE)
@PutMapping
public R<Void> edit(@Validated @RequestBody PgMember member) {
return toAjax(memberService.update(member));
public R<Void> edit(@Validated @RequestBody MemberSaveDto dto) {
memberService.updateWithEducations(dto);
return R.ok();
}
/**
@ -139,4 +146,59 @@ public class PgMemberController extends BaseController {
public R<Void> unbindStudent(@PathVariable Long studentId) {
return toAjax(studentService.unbindStudent(studentId));
}
// ==================== 教育身份管理 ====================
/**
* 获取会员教育身份列表
*/
@GetMapping("/{memberId}/educations")
public R<List<EducationVo>> getEducations(@PathVariable Long memberId) {
return R.ok(educationService.getEducationsByMemberId(memberId));
}
/**
* 添加教育身份
*/
@SaCheckPermission("business:member:edit")
@Log(title = "会员管理-添加教育身份", businessType = BusinessType.INSERT)
@PostMapping("/{memberId}/educations")
public R<Void> addEducation(@PathVariable Long memberId, @Validated @RequestBody EducationDto dto) {
educationService.addEducation(memberId, dto);
return R.ok();
}
/**
* 修改教育身份
*/
@SaCheckPermission("business:member:edit")
@Log(title = "会员管理-修改教育身份", businessType = BusinessType.UPDATE)
@PutMapping("/{memberId}/educations/{educationId}")
public R<Void> updateEducation(@PathVariable Long memberId, @PathVariable Long educationId,
@Validated @RequestBody EducationDto dto) {
educationService.updateEducation(memberId, educationId, dto);
return R.ok();
}
/**
* 删除教育身份
*/
@SaCheckPermission("business:member:edit")
@Log(title = "会员管理-删除教育身份", businessType = BusinessType.DELETE)
@DeleteMapping("/{memberId}/educations/{educationId}")
public R<Void> deleteEducation(@PathVariable Long memberId, @PathVariable Long educationId) {
educationService.deleteEducation(memberId, educationId);
return R.ok();
}
/**
* 设置默认教育身份
*/
@SaCheckPermission("business:member:edit")
@Log(title = "会员管理-设置默认身份", businessType = BusinessType.UPDATE)
@PutMapping("/{memberId}/educations/{educationId}/default")
public R<Void> setDefaultEducation(@PathVariable Long memberId, @PathVariable Long educationId) {
educationService.setDefaultEducation(memberId, educationId);
return R.ok();
}
}

View File

@ -1,7 +1,6 @@
package org.dromara.pangu.member.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
@ -10,7 +9,6 @@ import lombok.EqualsAndHashCode;
import org.dromara.common.mybatis.core.domain.BaseEntity;
import java.util.Date;
import java.util.List;
/**
* 会员表
@ -42,29 +40,10 @@ public class PgMember extends BaseEntity {
private Date birthday;
/**
* 身份类型1家长 2教师
*/
private String identityType;
private String openId;
private String unionId;
private Long regionId;
/**
* 区域ID路径非数据库字段用于级联选择器回显
*/
@TableField(exist = false)
private List<Long> regionIds;
private Long schoolId;
private Long schoolGradeId;
private Long schoolClassId;
/**
* 注册来源1小程序 2H5 3后台 4导入
*/

View File

@ -0,0 +1,33 @@
package org.dromara.pangu.member.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 教育身份请求DTO
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Schema(description = "教育身份请求")
public class EducationDto {
@Schema(description = "区域ID")
private Long regionId;
@NotNull(message = "学校不能为空")
@Schema(description = "学校ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long schoolId;
@NotNull(message = "年级不能为空")
@Schema(description = "年级IDschool_grade关联ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long schoolGradeId;
@NotNull(message = "班级不能为空")
@Schema(description = "班级IDschool_class关联ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long schoolClassId;
@Schema(description = "学科ID")
private Long subjectId;
}

View File

@ -0,0 +1,45 @@
package org.dromara.pangu.member.domain.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import java.util.Date;
import java.util.List;
/**
* 会员保存请求DTO支持同时保存教育身份
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Schema(description = "会员保存请求")
public class MemberSaveDto {
@Schema(description = "会员ID新增时不传编辑时必传")
private Long memberId;
@NotBlank(message = "手机号不能为空")
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
@Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED)
private String phone;
@Schema(description = "昵称")
private String nickname;
@Schema(description = "性别0未知 1男 2女")
private String gender;
@Schema(description = "出生日期")
private Date birthday;
@Schema(description = "状态0正常 1停用")
private String status;
@Schema(description = "教育身份列表")
private List<EducationDto> educations;
@Schema(description = "绑定的学生ID列表亲子关系")
private List<Long> studentIds;
}

View File

@ -0,0 +1,50 @@
package org.dromara.pangu.member.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 教育身份响应VO
*
* @author 湖北新华业务中台研发团队
*/
@Data
@Schema(description = "教育身份信息")
public class EducationVo {
@Schema(description = "教育身份ID")
private Long educationId;
@Schema(description = "区域ID")
private Long regionId;
@Schema(description = "区域名称(省/市/区)")
private String regionName;
@Schema(description = "学校ID")
private Long schoolId;
@Schema(description = "学校名称")
private String schoolName;
@Schema(description = "年级IDschool_grade关联ID")
private Long schoolGradeId;
@Schema(description = "年级名称")
private String gradeName;
@Schema(description = "班级IDschool_class关联ID")
private Long schoolClassId;
@Schema(description = "班级名称")
private String className;
@Schema(description = "学科ID")
private Long subjectId;
@Schema(description = "学科名称")
private String subjectName;
@Schema(description = "是否默认身份0否 1是")
private String isDefault;
}

View File

@ -0,0 +1,39 @@
package org.dromara.pangu.member.service;
import org.dromara.pangu.member.domain.dto.EducationDto;
import org.dromara.pangu.member.domain.vo.EducationVo;
import java.util.List;
/**
* 教育身份管理 Service 接口
*
* @author 湖北新华业务中台研发团队
*/
public interface IPgEducationService {
/**
* 获取会员的教育身份列表
*/
List<EducationVo> getEducationsByMemberId(Long memberId);
/**
* 添加教育身份
*/
void addEducation(Long memberId, EducationDto dto);
/**
* 修改教育身份
*/
void updateEducation(Long memberId, Long educationId, EducationDto dto);
/**
* 删除教育身份
*/
void deleteEducation(Long memberId, Long educationId);
/**
* 设置默认教育身份
*/
void setDefaultEducation(Long memberId, Long educationId);
}

View File

@ -3,6 +3,7 @@ package org.dromara.pangu.member.service;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.domain.dto.MemberSaveDto;
import java.util.List;
@ -33,6 +34,16 @@ public interface IPgMemberService {
*/
int insert(PgMember member);
/**
* 新增会员支持同时保存教育身份
*/
void insertWithEducations(MemberSaveDto dto);
/**
* 修改会员支持同时保存教育身份
*/
void updateWithEducations(MemberSaveDto dto);
/**
* 修改会员
*/

View File

@ -0,0 +1,267 @@
package org.dromara.pangu.member.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.pangu.base.domain.PgClass;
import org.dromara.pangu.base.domain.PgEducation;
import org.dromara.pangu.base.domain.PgGrade;
import org.dromara.pangu.base.domain.PgRegion;
import org.dromara.pangu.base.domain.PgSubject;
import org.dromara.pangu.base.mapper.PgClassMapper;
import org.dromara.pangu.base.mapper.PgEducationMapper;
import org.dromara.pangu.base.mapper.PgGradeMapper;
import org.dromara.pangu.base.mapper.PgRegionMapper;
import org.dromara.pangu.base.mapper.PgSubjectMapper;
import org.dromara.pangu.member.domain.dto.EducationDto;
import org.dromara.pangu.member.domain.vo.EducationVo;
import org.dromara.pangu.member.service.IPgEducationService;
import org.dromara.pangu.school.domain.PgSchool;
import org.dromara.pangu.school.domain.PgSchoolClass;
import org.dromara.pangu.school.domain.PgSchoolGrade;
import org.dromara.pangu.school.mapper.PgSchoolClassMapper;
import org.dromara.pangu.school.mapper.PgSchoolGradeMapper;
import org.dromara.pangu.school.mapper.PgSchoolMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/**
* 教育身份管理 Service 实现
*
* @author 湖北新华业务中台研发团队
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class PgEducationServiceImpl implements IPgEducationService {
private final PgEducationMapper educationMapper;
private final PgSchoolMapper schoolMapper;
private final PgSchoolGradeMapper schoolGradeMapper;
private final PgSchoolClassMapper schoolClassMapper;
private final PgGradeMapper gradeMapper;
private final PgClassMapper classMapper;
private final PgSubjectMapper subjectMapper;
private final PgRegionMapper regionMapper;
@Override
public List<EducationVo> getEducationsByMemberId(Long memberId) {
List<PgEducation> educations = educationMapper.selectList(
new LambdaQueryWrapper<PgEducation>()
.eq(PgEducation::getMemberId, memberId)
.eq(PgEducation::getDelFlag, "0")
.orderByDesc(PgEducation::getIsDefault)
.orderByDesc(PgEducation::getCreateTime)
);
List<EducationVo> voList = new ArrayList<>();
for (PgEducation education : educations) {
voList.add(buildEducationVo(education));
}
return voList;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void addEducation(Long memberId, EducationDto dto) {
// 校验学校信息
PgSchool school = validateAndGetSchool(dto);
// 创建教育身份
PgEducation education = new PgEducation();
education.setMemberId(memberId);
education.setRegionId(school.getRegionId());
education.setSchoolId(dto.getSchoolId());
education.setSchoolGradeId(dto.getSchoolGradeId());
education.setSchoolClassId(dto.getSchoolClassId());
education.setSubjectId(dto.getSubjectId());
education.setStatus("0");
// 如果是第一个教育身份设为默认
long count = educationMapper.selectCount(
new LambdaQueryWrapper<PgEducation>()
.eq(PgEducation::getMemberId, memberId)
.eq(PgEducation::getDelFlag, "0")
);
education.setIsDefault(count == 0 ? "1" : "0");
educationMapper.insert(education);
log.info("添加教育身份: memberId={}, educationId={}", memberId, education.getEducationId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateEducation(Long memberId, Long educationId, EducationDto dto) {
// 查询并校验归属
PgEducation education = educationMapper.selectById(educationId);
if (education == null || !memberId.equals(education.getMemberId())) {
throw new ServiceException("教育身份不存在或无权限修改");
}
// 校验学校信息
PgSchool school = validateAndGetSchool(dto);
// 更新
education.setRegionId(school.getRegionId());
education.setSchoolId(dto.getSchoolId());
education.setSchoolGradeId(dto.getSchoolGradeId());
education.setSchoolClassId(dto.getSchoolClassId());
education.setSubjectId(dto.getSubjectId());
educationMapper.updateById(education);
log.info("修改教育身份: memberId={}, educationId={}", memberId, educationId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteEducation(Long memberId, Long educationId) {
PgEducation education = educationMapper.selectById(educationId);
if (education == null || !memberId.equals(education.getMemberId())) {
throw new ServiceException("教育身份不存在或无权限删除");
}
educationMapper.deleteById(educationId);
log.info("删除教育身份: memberId={}, educationId={}", memberId, educationId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void setDefaultEducation(Long memberId, Long educationId) {
PgEducation education = educationMapper.selectById(educationId);
if (education == null || !memberId.equals(education.getMemberId())) {
throw new ServiceException("教育身份不存在或无权限操作");
}
// 取消其他默认
PgEducation updateEntity = new PgEducation();
updateEntity.setIsDefault("0");
educationMapper.update(updateEntity,
new LambdaQueryWrapper<PgEducation>()
.eq(PgEducation::getMemberId, memberId)
.eq(PgEducation::getIsDefault, "1")
);
// 设置当前为默认
education.setIsDefault("1");
educationMapper.updateById(education);
log.info("设置默认教育身份: memberId={}, educationId={}", memberId, educationId);
}
/**
* 校验学校信息
*/
private PgSchool validateAndGetSchool(EducationDto dto) {
PgSchool school = schoolMapper.selectById(dto.getSchoolId());
if (school == null) {
throw new ServiceException("学校不存在");
}
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(dto.getSchoolGradeId());
if (schoolGrade == null || !schoolGrade.getSchoolId().equals(dto.getSchoolId())) {
throw new ServiceException("年级不存在或不属于该学校");
}
PgSchoolClass schoolClass = schoolClassMapper.selectById(dto.getSchoolClassId());
if (schoolClass == null || !schoolClass.getSchoolGradeId().equals(dto.getSchoolGradeId())) {
throw new ServiceException("班级不存在或不属于该年级");
}
if (dto.getSubjectId() != null) {
PgSubject subject = subjectMapper.selectById(dto.getSubjectId());
if (subject == null) {
throw new ServiceException("学科不存在");
}
}
return school;
}
/**
* 构建教育身份VO
*/
private EducationVo buildEducationVo(PgEducation education) {
EducationVo vo = new EducationVo();
vo.setEducationId(education.getEducationId());
vo.setSchoolId(education.getSchoolId());
vo.setSchoolGradeId(education.getSchoolGradeId());
vo.setSchoolClassId(education.getSchoolClassId());
vo.setSubjectId(education.getSubjectId());
vo.setIsDefault(education.getIsDefault());
// 获取学校和区域信息
PgSchool school = schoolMapper.selectById(education.getSchoolId());
if (school != null) {
vo.setSchoolName(school.getSchoolName());
vo.setRegionId(school.getRegionId());
// 构建区域全名
if (school.getRegionId() != null) {
vo.setRegionName(buildRegionFullName(school.getRegionId()));
}
}
// 获取年级名称
PgSchoolGrade schoolGrade = schoolGradeMapper.selectById(education.getSchoolGradeId());
if (schoolGrade != null && schoolGrade.getGradeId() != null) {
PgGrade grade = gradeMapper.selectById(schoolGrade.getGradeId());
if (grade != null) {
vo.setGradeName(grade.getGradeName());
}
}
// 获取班级名称
PgSchoolClass schoolClass = schoolClassMapper.selectById(education.getSchoolClassId());
if (schoolClass != null && schoolClass.getClassId() != null) {
PgClass cls = classMapper.selectById(schoolClass.getClassId());
if (cls != null) {
vo.setClassName(cls.getClassName());
}
}
// 获取学科名称
if (education.getSubjectId() != null) {
PgSubject subject = subjectMapper.selectById(education.getSubjectId());
if (subject != null) {
vo.setSubjectName(subject.getSubjectName());
}
}
return vo;
}
/**
* 构建区域全名//
*/
private String buildRegionFullName(Long regionId) {
PgRegion district = regionMapper.selectById(regionId);
if (district == null) {
return null;
}
StringBuilder sb = new StringBuilder();
if (StringUtils.isNotBlank(district.getAncestors())) {
String[] ancestorIds = district.getAncestors().split(",");
for (String idStr : ancestorIds) {
if (!"0".equals(idStr.trim())) {
try {
PgRegion ancestor = regionMapper.selectById(Long.parseLong(idStr.trim()));
if (ancestor != null) {
if (sb.length() > 0) sb.append("/");
sb.append(ancestor.getRegionName());
}
} catch (NumberFormatException ignored) {
}
}
}
}
if (sb.length() > 0) sb.append("/");
sb.append(district.getRegionName());
return sb.toString();
}
}

View File

@ -5,22 +5,23 @@ import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.mybatis.core.page.PageQuery;
import org.dromara.common.mybatis.core.page.TableDataInfo;
import org.dromara.pangu.base.domain.PgRegion;
import org.dromara.pangu.base.mapper.PgRegionMapper;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.domain.dto.EducationDto;
import org.dromara.pangu.member.domain.dto.MemberSaveDto;
import org.dromara.pangu.member.mapper.PgMemberMapper;
import org.dromara.pangu.member.service.IPgEducationService;
import org.dromara.pangu.member.service.IPgMemberService;
import org.dromara.pangu.student.mapper.PgStudentMapper;
import org.dromara.pangu.student.service.IPgStudentService;
import cn.hutool.crypto.digest.BCrypt;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@ -29,13 +30,15 @@ import java.util.List;
*
* @author pangu
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class PgMemberServiceImpl implements IPgMemberService {
private final PgMemberMapper baseMapper;
private final PgStudentMapper studentMapper;
private final PgRegionMapper regionMapper;
private final IPgEducationService educationService;
private final IPgStudentService studentService;
private static final String DEFAULT_PASSWORD = "123456";
@ -53,29 +56,7 @@ public class PgMemberServiceImpl implements IPgMemberService {
@Override
public PgMember selectById(Long memberId) {
PgMember member = baseMapper.selectById(memberId);
if (member != null && member.getRegionId() != null) {
// 查询区域的完整路径用于级联选择器回显
member.setRegionIds(getRegionPath(member.getRegionId()));
}
return member;
}
/**
* 获取区域的完整路径从根到当前节点的ID列表
*/
private List<Long> getRegionPath(Long regionId) {
List<Long> path = new ArrayList<>();
Long currentId = regionId;
while (currentId != null && currentId > 0) {
path.add(0, currentId);
PgRegion region = regionMapper.selectById(currentId);
if (region == null) {
break;
}
currentId = region.getParentId();
}
return path;
return baseMapper.selectById(memberId);
}
@Override
@ -112,12 +93,63 @@ public class PgMemberServiceImpl implements IPgMemberService {
member.setStatus("0");
}
// 教师身份校验必填字段
validateTeacherInfo(member);
return baseMapper.insert(member);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void insertWithEducations(MemberSaveDto dto) {
log.info("新增会员: phone={}, educations={}, studentIds={}",
dto.getPhone(),
dto.getEducations() != null ? dto.getEducations().size() : 0,
dto.getStudentIds() != null ? dto.getStudentIds() : "null");
// 创建会员
PgMember member = new PgMember();
member.setPhone(dto.getPhone());
member.setNickname(dto.getNickname());
member.setGender(dto.getGender());
member.setBirthday(dto.getBirthday());
member.setStatus(dto.getStatus());
insert(member);
Long memberId = member.getMemberId();
// 保存教育身份
if (dto.getEducations() != null && !dto.getEducations().isEmpty()) {
log.info("保存教育身份: memberId={}, count={}", memberId, dto.getEducations().size());
for (EducationDto eduDto : dto.getEducations()) {
educationService.addEducation(memberId, eduDto);
}
}
// 绑定学生亲子关系
if (dto.getStudentIds() != null && !dto.getStudentIds().isEmpty()) {
log.info("绑定学生: memberId={}, studentIds={}", memberId, dto.getStudentIds());
studentService.bindStudentsToMember(memberId, dto.getStudentIds());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void updateWithEducations(MemberSaveDto dto) {
if (dto.getMemberId() == null) {
throw new ServiceException("会员ID不能为空");
}
// 更新会员基本信息
PgMember member = new PgMember();
member.setMemberId(dto.getMemberId());
member.setPhone(dto.getPhone());
member.setNickname(dto.getNickname());
member.setGender(dto.getGender());
member.setBirthday(dto.getBirthday());
member.setStatus(dto.getStatus());
update(member);
// 注意编辑时教育身份通过单独的接口管理这里不处理
}
@Override
@Transactional(rollbackFor = Exception.class)
public int update(PgMember member) {
@ -126,17 +158,6 @@ public class PgMemberServiceImpl implements IPgMemberService {
throw new ServiceException("手机号已存在");
}
// 教师身份校验必填字段
validateTeacherInfo(member);
// 家长身份清空学校信息
if ("1".equals(member.getIdentityType())) {
member.setRegionId(null);
member.setSchoolId(null);
member.setSchoolGradeId(null);
member.setSchoolClassId(null);
}
// 不更新密码密码通过重置接口更新
member.setPassword(null);
@ -223,31 +244,10 @@ public class PgMemberServiceImpl implements IPgMemberService {
);
}
/**
* 校验教师身份必填字段
*/
private void validateTeacherInfo(PgMember member) {
if ("2".equals(member.getIdentityType())) {
if (member.getRegionId() == null) {
throw new ServiceException("教师身份必须选择所属区域");
}
if (member.getSchoolId() == null) {
throw new ServiceException("教师身份必须选择所属学校");
}
if (member.getSchoolGradeId() == null) {
throw new ServiceException("教师身份必须选择所属年级");
}
if (member.getSchoolClassId() == null) {
throw new ServiceException("教师身份必须选择所属班级");
}
}
}
private LambdaQueryWrapper<PgMember> buildQueryWrapper(PgMember member) {
LambdaQueryWrapper<PgMember> lqw = new LambdaQueryWrapper<>();
lqw.like(StrUtil.isNotBlank(member.getNickname()), PgMember::getNickname, member.getNickname());
lqw.like(StrUtil.isNotBlank(member.getPhone()), PgMember::getPhone, member.getPhone());
lqw.eq(StrUtil.isNotBlank(member.getIdentityType()), PgMember::getIdentityType, member.getIdentityType());
lqw.eq(StrUtil.isNotBlank(member.getStatus()), PgMember::getStatus, member.getStatus());
lqw.orderByDesc(PgMember::getCreateTime);
return lqw;

View File

@ -89,7 +89,7 @@ public class PgStudentController extends BaseController {
* @param schoolId 学校ID教师身份时传入限制只能选本校学生
*/
@GetMapping("/available")
public TableDataInfo<PgStudent> availableStudents(
public TableDataInfo<StudentVo> availableStudents(
@RequestParam(required = false) String studentName,
@RequestParam(required = false) String studentNo,
@RequestParam(required = false) Long memberId,

View File

@ -30,7 +30,7 @@ public interface IPgStudentService {
* @param schoolId 学校ID教师身份时必传限制只能选本校学生
* @param pageQuery 分页参数
*/
TableDataInfo<PgStudent> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery);
TableDataInfo<StudentVo> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery);
/**
* 查询会员已绑定的学生列表包含学校年级班级名称

View File

@ -14,6 +14,8 @@ import org.dromara.pangu.base.domain.PgClass;
import org.dromara.pangu.base.domain.PgGrade;
import org.dromara.pangu.base.mapper.PgClassMapper;
import org.dromara.pangu.base.mapper.PgGradeMapper;
import org.dromara.pangu.base.domain.PgEducation;
import org.dromara.pangu.base.mapper.PgEducationMapper;
import org.dromara.pangu.member.domain.PgMember;
import org.dromara.pangu.member.mapper.PgMemberMapper;
import org.dromara.pangu.school.domain.PgSchool;
@ -52,6 +54,7 @@ public class PgStudentServiceImpl implements IPgStudentService {
private final PgGradeMapper gradeMapper;
private final PgClassMapper classMapper;
private final PgMemberMapper memberMapper;
private final PgEducationMapper educationMapper;
@Override
public TableDataInfo<StudentVo> selectPageList(PgStudent student, PageQuery pageQuery) {
@ -211,7 +214,7 @@ public class PgStudentServiceImpl implements IPgStudentService {
}
@Override
public TableDataInfo<PgStudent> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery) {
public TableDataInfo<StudentVo> selectAvailableStudents(String studentName, String studentNo, Long memberId, Long schoolId, PageQuery pageQuery) {
LambdaQueryWrapper<PgStudent> lqw = new LambdaQueryWrapper<>();
lqw.like(StrUtil.isNotBlank(studentName), PgStudent::getStudentName, studentName);
lqw.like(StrUtil.isNotBlank(studentNo), PgStudent::getStudentNo, studentNo);
@ -224,7 +227,9 @@ public class PgStudentServiceImpl implements IPgStudentService {
lqw.orderByDesc(PgStudent::getCreateTime);
Page<PgStudent> page = baseMapper.selectPage(pageQuery.build(), lqw);
return TableDataInfo.build(page);
// 转换为 VO填充学校年级班级名称
List<StudentVo> voList = convertToVoList(page.getRecords());
return new TableDataInfo<>(voList, page.getTotal());
}
@Override
@ -344,11 +349,18 @@ public class PgStudentServiceImpl implements IPgStudentService {
PgMember member = findOrCreateMember(dto.getMemberPhone().trim());
Long memberId = member.getMemberId();
// 6. 教师身份校验教师的区域/学校/年级/班级必须与学生一致
if ("2".equals(member.getIdentityType())) {
String teacherError = validateTeacherStudent(member, school, schoolGrade, schoolClass);
if (teacherError != null) {
failList.add(createFailItem(rowNum, teacherError));
// 6. 教师身份校验检查会员是否有匹配的教育身份
List<PgEducation> educations = educationMapper.selectList(
new LambdaQueryWrapper<PgEducation>()
.eq(PgEducation::getMemberId, memberId)
.eq(PgEducation::getDelFlag, "0")
);
if (!educations.isEmpty()) {
// 会员有教育身份检查是否包含学生所在的班级
boolean hasMatchingClass = educations.stream()
.anyMatch(e -> schoolClass.getId().equals(e.getSchoolClassId()));
if (!hasMatchingClass) {
failList.add(createFailItem(rowNum, "该教师未管理学生所在班级"));
continue;
}
}
@ -445,12 +457,11 @@ public class PgStudentServiceImpl implements IPgStudentService {
return member;
}
// 不存在则创建新会员身份为家长初始密码123456
// 不存在则创建新会员初始密码123456
PgMember newMember = new PgMember();
newMember.setMemberCode(generateMemberCode()); // 生成会员编码
newMember.setPhone(phone);
newMember.setNickname("家长" + phone.substring(7)); // 默认昵称
newMember.setIdentityType("1"); // 家长
newMember.setPassword(BCrypt.hashpw("123456")); // 初始密码
newMember.setStatus("0"); // 正常
newMember.setRegisterSource("4"); // 批量导入
@ -460,44 +471,6 @@ public class PgStudentServiceImpl implements IPgStudentService {
return newMember;
}
/**
* 校验教师与学生的归属关系
* 教师的区域/学校/年级/班级必须与学生一致
*/
private String validateTeacherStudent(PgMember teacher, PgSchool studentSchool,
PgSchoolGrade studentGrade, PgSchoolClass studentClass) {
String teacherInfo = "教师\"" + (teacher.getNickname() != null ? teacher.getNickname() : "未知")
+ "\"(" + teacher.getPhone() + ")";
// 检查教师是否设置了学校信息
if (teacher.getSchoolId() == null || teacher.getSchoolGradeId() == null || teacher.getSchoolClassId() == null) {
return teacherInfo + "未设置学校信息,无法绑定学生";
}
// 校验区域通过学校的区域ID间接校验
if (teacher.getRegionId() != null && studentSchool.getRegionId() != null) {
if (!teacher.getRegionId().equals(studentSchool.getRegionId())) {
return teacherInfo + "所属区域与学生不一致";
}
}
// 校验学校
if (!teacher.getSchoolId().equals(studentSchool.getSchoolId())) {
return teacherInfo + "所属学校与学生不一致";
}
// 校验年级
if (!teacher.getSchoolGradeId().equals(studentGrade.getId())) {
return teacherInfo + "所属年级与学生不一致";
}
// 校验班级
if (!teacher.getSchoolClassId().equals(studentClass.getId())) {
return teacherInfo + "所属班级与学生不一致";
}
return null; // 校验通过
}
/**
* 生成会员编码M + 年月日时分秒毫秒 + 4位随机数

View File

@ -0,0 +1,660 @@
# 会员教育信息重构方案
> 作者:湖北新华业务中台研发团队
> 创建时间2026-02-03
> 状态:**已审核通过**
---
## 一、需求变更说明
### 1.1 变更内容
1. **去掉身份类型**:会员不再区分"家长"和"教师"
2. **重构会员信息表**:会员与教育信息变为**一对多**关系,一个会员可以有多个教育身份
3. **pg_member 表移除字段**
- `region_id` - 区域ID
- `school_id` - 学校ID
- `school_grade_id` - 年级ID
- `school_class_id` - 班级ID
- `identity_type` - 身份类型
- ~~`union_id`~~ - **保留**(微信多应用打通需要)
### 1.2 变更原因
- 业务场景:一个用户可能同时是多个学校/班级的教师
- 简化会员模型:会员表只存储基础信息,教育信息独立管理
---
## 二、当前架构分析
### 2.1 现有数据结构
**pg_member 表**(部分字段):
| 字段 | 类型 | 说明 | 变更 |
|------|------|------|------|
| member_id | bigint | 主键 | 保留 |
| member_code | varchar(32) | 会员编号 | 保留 |
| phone | varchar(20) | 手机号 | 保留 |
| password | varchar(100) | 密码 | 保留 |
| nickname | varchar(50) | 昵称 | 保留 |
| avatar | varchar(500) | 头像 | 保留 |
| gender | char(1) | 性别 | 保留 |
| birthday | date | 生日 | 保留 |
| **identity_type** | char(1) | 身份类型 | **删除** |
| open_id | varchar(100) | 微信OpenID | 保留 |
| union_id | varchar(100) | 微信UnionID | 保留 |
| **region_id** | bigint | 区域ID | **删除** |
| **school_id** | bigint | 学校ID | **删除** |
| **school_grade_id** | bigint | 年级ID | **删除** |
| **school_class_id** | bigint | 班级ID | **删除** |
| register_source | char(1) | 注册来源 | 保留 |
| register_time | datetime | 注册时间 | 保留 |
| status | char(1) | 状态 | 保留 |
### 2.2 现有关联关系
```
pg_member会员
├── 一对一:教育信息(存在会员表中) ← 需要改为一对多
└── 一对多pg_student学生 ← 保持不变
```
---
## 三、目标架构设计
### 3.1 新数据结构
```
pg_member会员- 只存基础信息
├── 一对多pg_member_education会员教育信息 ← 新建
└── 一对多pg_student学生 ← 保持不变
```
### 3.2 新建表pg_member_education
**设计理念**:一个教育身份 = 一个班级的教学关系
```
张老师教3个班 → 3条教育身份记录
教育身份1: 二中 - 高二 - 1班 - 数学
教育身份2: 二中 - 高二 - 2班 - 数学
教育身份3: 二中 - 高二 - 3班 - 数学
```
```sql
CREATE TABLE pg_member_education (
education_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '教育身份ID',
member_id BIGINT NOT NULL COMMENT '会员ID',
region_id BIGINT COMMENT '区域ID',
school_id BIGINT NOT NULL COMMENT '学校ID',
school_grade_id BIGINT NOT NULL COMMENT '年级IDschool_grade关联ID',
school_class_id BIGINT NOT NULL COMMENT '班级IDschool_class关联ID',
subject_id BIGINT COMMENT '学科ID',
is_default CHAR(1) DEFAULT '0' COMMENT '是否默认身份0否 1是',
status CHAR(1) DEFAULT '0' COMMENT '状态0正常 1停用',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志',
KEY idx_member_id (member_id),
KEY idx_school_id (school_id),
KEY idx_class_id (school_class_id)
) COMMENT '会员教育信息表';
```
### 3.3 最终关系图
```
pg_member会员基础信息
├── 一对多 → pg_member_education教育身份每班一条
└── 一对多 → pg_student学生/孩子)
```
---
## 四、影响范围分析
### 4.1 数据库层
| 操作 | 对象 | 说明 |
|------|------|------|
| 修改 | pg_member | 删除5个字段 |
| 新建 | pg_member_education | 会员教育信息表(含班级字段) |
| 迁移 | 现有教育数据 | 迁移到新表 |
### 4.2 后端层
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `PgMember.java` | 修改 | 删除5个字段保留unionId |
| `PgMemberEducation.java` | 新建 | 教育身份实体 |
| `PgMemberEducationMapper.java` | 新建 | Mapper |
| `H5MemberServiceImpl.java` | 重构 | 教育相关逻辑 |
| `H5EducationDto.java` | 修改 | 添加educationId |
| `H5EducationVo.java` | 修改 | 添加educationId等字段 |
| `H5MemberController.java` | 修改 | 接口调整 |
| `H5MemberInfoVo.java` | 修改 | 删除identityType |
| `PgMemberController.java` | 修改 | 删除身份类型相关 |
| `PgMemberServiceImpl.java` | 修改 | 删除教育信息处理 |
### 4.3 管理后台前端
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `business/member/index.vue` | 修改 | 删除身份类型筛选和列 |
| `business/member/components/MemberDialog.vue` | 重构 | 删除身份类型和教师信息区块 |
| `business/member/components/EducationTab.vue` | **新建** | 教育身份管理Tab |
| `business/member/components/MemberDetail.vue` | **新建** | 会员详情页含教育身份Tab |
| `api/pangu/member.js` | 修改 | 删除/新增接口 |
| `api/pangu/memberEducation.js` | **新建** | 教育身份接口 |
### 4.4 H5前端
| 文件 | 改动类型 | 说明 |
|------|----------|------|
| `stores/user.ts` | 修改 | 删除 identityType |
| `views/userCenter/index.vue` | 修改 | 教育身份列表支持多个 |
| `components/TeacherIdentityForm.vue` | 修改 | 新增/编辑某个教育身份 |
| `api/user.js` | 修改 | 接口调整 |
---
## 五、接口变更
### 5.1 H5接口变更
#### 5.1.1 获取会员信息(修改)
**接口**`GET /h5/member/info`
**响应变更**
```json
// 修改前
{
"code": 200,
"data": {
"consumerId": 1,
"phone": "138****0000",
"nickname": "测试用户",
"identityType": "2", // ← 删除
"education": { ... }, // 单个
"students": [...]
}
}
// 修改后
{
"code": 200,
"data": {
"consumerId": 1,
"phone": "138****0000",
"nickname": "测试用户",
"educations": [...], // 多个教育身份
"students": [...]
}
}
```
#### 5.1.2 教育身份列表(修改)
**接口**`GET /h5/member/educations`
**响应**
```json
{
"code": 200,
"data": [
{
"educationId": 1,
"schoolId": 1,
"schoolName": "武汉市第二中学",
"schoolGradeId": 5,
"gradeName": "高二",
"schoolClassId": 104,
"className": "1班",
"subjectId": 1,
"subjectName": "数学",
"isDefault": "1"
},
{
"educationId": 2,
"schoolId": 1,
"schoolName": "武汉市第二中学",
"schoolGradeId": 5,
"gradeName": "高二",
"schoolClassId": 105,
"className": "2班",
"subjectId": 1,
"subjectName": "数学",
"isDefault": "0"
}
]
}
```
#### 5.1.3 新增教育身份
**接口**`POST /h5/member/educations`
**请求参数**
```json
{
"schoolId": 1,
"schoolGradeId": 5,
"schoolClassId": 104,
"subjectId": 1
}
```
#### 5.1.4 编辑教育身份
**接口**`PUT /h5/member/educations/{educationId}`
**请求参数**
```json
{
"schoolId": 1,
"schoolGradeId": 5,
"schoolClassId": 104,
"subjectId": 1
}
```
#### 5.1.5 删除教育身份
**接口**`DELETE /h5/member/educations/{educationId}`
#### 5.1.6 设置默认教育身份
**接口**`PUT /h5/member/educations/{educationId}/default`
### 5.2 管理后台接口变更
#### 5.2.1 会员列表(修改)
- 删除 `identityType` 筛选参数
- 响应中删除 `identityType` 字段
#### 5.2.2 会员详情(修改)
- 删除教育信息字段
- 新增 `educations` 列表
#### 5.2.3 新增/编辑会员(修改)
- 删除 `identityType`、`regionId`、`schoolId`、`schoolGradeId`、`schoolClassId` 参数
#### 5.2.4 会员教育身份管理(新增)
| 接口 | 方法 | 说明 |
|------|------|------|
| `/business/member/{memberId}/educations` | GET | 获取会员教育身份列表 |
| `/business/member/{memberId}/education` | POST | 添加教育身份 |
| `/business/member/{memberId}/education/{educationId}` | PUT | 修改教育身份 |
| `/business/member/{memberId}/education/{educationId}` | DELETE | 删除教育身份 |
---
## 六、详细技术方案
### 6.1 数据库改造
#### 6.1.1 DDL脚本
```sql
-- 新建会员教育信息表(一个教育身份 = 一个班级的教学关系)
CREATE TABLE pg_member_education (
education_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '教育身份ID',
member_id BIGINT NOT NULL COMMENT '会员ID',
region_id BIGINT COMMENT '区域ID',
school_id BIGINT NOT NULL COMMENT '学校ID',
school_grade_id BIGINT NOT NULL COMMENT '年级ID',
school_class_id BIGINT NOT NULL COMMENT '班级ID',
subject_id BIGINT COMMENT '学科ID',
is_default CHAR(1) DEFAULT '0' COMMENT '是否默认身份0否 1是',
status CHAR(1) DEFAULT '0' COMMENT '状态0正常 1停用',
tenant_id VARCHAR(20) DEFAULT '000000' COMMENT '租户编号',
create_dept BIGINT COMMENT '创建部门',
create_by BIGINT COMMENT '创建者',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_by BIGINT COMMENT '更新者',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
del_flag CHAR(1) DEFAULT '0' COMMENT '删除标志',
KEY idx_member_id (member_id),
KEY idx_school_id (school_id),
KEY idx_class_id (school_class_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='会员教育信息表';
```
#### 6.1.2 数据迁移脚本
```sql
-- 迁移现有教育数据identity_type = '2' 的教师数据)
INSERT INTO pg_member_education (
member_id, region_id, school_id, school_grade_id, school_class_id, subject_id, is_default, status, tenant_id, create_time
)
SELECT
member_id, region_id, school_id, school_grade_id, school_class_id, NULL, '1', '0', tenant_id, NOW()
FROM pg_member
WHERE identity_type = '2'
AND school_id IS NOT NULL
AND school_class_id IS NOT NULL
AND del_flag = '0';
```
#### 6.1.3 删除字段(迁移完成后执行)
```sql
-- 删除 pg_member 表中的字段(建议先备份)
-- 注意union_id 保留,不删除
ALTER TABLE pg_member
DROP COLUMN identity_type,
DROP COLUMN region_id,
DROP COLUMN school_id,
DROP COLUMN school_grade_id,
DROP COLUMN school_class_id;
```
### 6.2 后端改造
#### 6.2.1 新建实体类
**PgMemberEducation.java**
```java
package org.dromara.pangu.member.domain;
@Data
@TableName("pg_member_education")
public class PgMemberEducation extends BaseEntity {
@TableId(type = IdType.AUTO)
private Long educationId;
private Long memberId;
private Long regionId;
private Long schoolId;
private Long schoolGradeId;
private Long schoolClassId;
private Long subjectId;
private String isDefault;
private String status;
private String tenantId;
@TableLogic
private String delFlag;
// 非数据库字段用于VO展示
@TableField(exist = false)
private String schoolName;
@TableField(exist = false)
private String gradeName;
@TableField(exist = false)
private String className;
@TableField(exist = false)
private String subjectName;
}
```
#### 6.2.2 修改 PgMember.java
删除以下字段:
- identityType
- regionId
- regionIds@TableField(exist = false)
- schoolId
- schoolGradeId
- schoolClassId
**保留字段**
- unionId微信多应用打通需要
#### 6.2.3 新建 Mapper
- PgMemberEducationMapper.java
#### 6.2.4 修改 H5 Service
**H5MemberServiceImpl.java 主要改动**
1. `getEducation()``getEducations()` 返回列表
2. `saveEducation()` 支持新增/编辑
3. `deleteEducation(Long educationId)` 按ID删除
4. 新增 `setDefaultEducation(Long educationId)` 设置默认
### 6.3 管理后台前端改造
#### 6.3.1 会员列表页index.vue
**删除内容**
```vue
<!-- 删除:身份类型筛选 -->
<el-form-item label="身份类型">
<el-select v-model="queryParams.identityType" ... />
</el-form-item>
<!-- 删除:身份类型列 -->
<el-table-column prop="identityType" label="身份类型" ... />
```
#### 6.3.2 会员编辑弹窗MemberDialog.vue
**删除内容**
```vue
<!-- 删除:身份类型选择 -->
<el-form-item label="身份类型" prop="identityType">
<el-radio-group v-model="form.identityType" ... />
</el-form-item>
<!-- 删除:教师信息区块 -->
<template v-if="form.identityType === '2'">
<el-divider>学校信息(教师必填)</el-divider>
<!-- 区域、学校、年级、班级选择 -->
</template>
```
#### 6.3.3 新建会员详情页
**MemberDetail.vue**(新建)
```vue
<template>
<el-dialog v-model="visible" title="会员详情" width="900px">
<el-tabs v-model="activeTab">
<el-tab-pane label="基本信息" name="basic">
<!-- 会员基本信息展示 -->
</el-tab-pane>
<el-tab-pane label="教育身份" name="education">
<EducationTab :member-id="memberId" />
</el-tab-pane>
<el-tab-pane label="绑定学生" name="students">
<!-- 学生列表 -->
</el-tab-pane>
</el-tabs>
</el-dialog>
</template>
```
#### 6.3.4 新建教育身份Tab
**EducationTab.vue**(新建)
```vue
<template>
<div>
<el-button type="primary" @click="handleAdd">添加教育身份</el-button>
<el-table :data="educationList">
<el-table-column prop="schoolName" label="学校" />
<el-table-column prop="gradeName" label="年级" />
<el-table-column label="班级">
<template #default="{ row }">
{{ row.classNames?.join('、') }}
</template>
</el-table-column>
<el-table-column prop="subjectName" label="学科" />
<el-table-column prop="isDefault" label="默认" width="80">
<template #default="{ row }">
<el-tag v-if="row.isDefault === '1'" type="success"></el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button link @click="handleEdit(row)">编辑</el-button>
<el-button link @click="handleSetDefault(row)">设为默认</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 编辑弹窗 -->
<EducationDialog ref="dialogRef" @success="loadData" />
</div>
</template>
```
### 6.4 H5前端改造
#### 6.4.1 user.ts 修改
```typescript
// 删除
identityType: string
// 修改
education: any // 单个 →
educations: any[] // 多个
```
#### 6.4.2 userCenter/index.vue 修改
支持展示多个教育身份,而不是只展示一个。
#### 6.4.3 TeacherIdentityForm.vue 修改
新增 `educationId` 字段用于编辑。
---
## 七、文件改动清单
### 7.1 数据库
| 文件/操作 | 类型 |
|-----------|------|
| DDL: pg_member_education | 新建表(含 school_class_id |
| DML: 数据迁移脚本 | 执行 |
| DDL: pg_member 删除字段 | 修改表 |
### 7.2 后端(共 12+ 文件)
| 文件 | 类型 | 说明 |
|------|------|------|
| `member/domain/PgMember.java` | 修改 | 删除5个字段保留unionId |
| `member/domain/PgMemberEducation.java` | 新建 | |
| `member/mapper/PgMemberEducationMapper.java` | 新建 | |
| `member/service/PgMemberEducationService.java` | 新建 | |
| `member/service/impl/PgMemberEducationServiceImpl.java` | 新建 | |
| `member/controller/PgMemberController.java` | 修改 | 删除身份类型相关 |
| `member/service/impl/PgMemberServiceImpl.java` | 修改 | |
| `h5/domain/dto/H5EducationDto.java` | 修改 | 添加educationId |
| `h5/domain/vo/H5EducationVo.java` | 修改 | |
| `h5/domain/vo/H5MemberInfoVo.java` | 修改 | 删除identityType |
| `h5/controller/H5MemberController.java` | 修改 | 接口调整 |
| `h5/service/impl/H5MemberServiceImpl.java` | 重构 | 教育相关逻辑 |
### 7.3 管理后台前端(共 8+ 文件)
| 文件 | 类型 | 说明 |
|------|------|------|
| `views/business/member/index.vue` | 修改 | 删除身份类型 |
| `views/business/member/components/MemberDialog.vue` | 重构 | 删除教师信息 |
| `views/business/member/components/MemberDetail.vue` | 新建 | 会员详情 |
| `views/business/member/components/EducationTab.vue` | 新建 | 教育身份Tab |
| `views/business/member/components/EducationDialog.vue` | 新建 | 教育身份编辑 |
| `api/pangu/member.js` | 修改 | |
| `api/pangu/memberEducation.js` | 新建 | |
### 7.4 H5前端共 5+ 文件)
| 文件 | 类型 | 说明 |
|------|------|------|
| `stores/user.ts` | 修改 | 删除identityType |
| `views/userCenter/index.vue` | 修改 | 支持多教育身份 |
| `components/TeacherIdentityForm.vue` | 修改 | 添加educationId |
| `api/user.js` | 修改 | 接口调整 |
| `views/register/index.vue` | 可能修改 | 如有身份类型相关 |
---
## 八、测试要点
| 场景 | 测试内容 |
|------|----------|
| 数据迁移 | 现有教师数据正确迁移到新表 |
| H5-新增教育身份 | 首次添加、多次添加(每班一条记录) |
| H5-编辑教育身份 | 修改学校/年级/班级/学科 |
| H5-删除教育身份 | 删除非默认、删除默认 |
| H5-设置默认 | 切换默认身份 |
| H5-多教育身份 | 一个老师添加多条教育身份(教多个班) |
| 管理后台-会员列表 | 不显示身份类型 |
| 管理后台-会员详情 | 查看多个教育身份 |
| 管理后台-教育身份管理 | 增删改查 |
---
## 九、上线计划
| 步骤 | 内容 | 说明 |
|------|------|------|
| 1 | 备份数据库 | 全量备份 pg_member |
| 2 | 执行建表DDL | 创建新表 |
| 3 | 执行数据迁移 | 迁移现有教育数据 |
| 4 | 部署后端 | 新代码 |
| 5 | 部署管理后台前端 | 新代码 |
| 6 | 部署H5前端 | 新代码 |
| 7 | 验证测试 | 回归测试 |
| 8 | 执行删除字段DDL | 确认无问题后 |
---
## 十、回滚方案
1. 后端代码回滚
2. 前端代码回滚
3. 数据库:
- 从新表恢复数据到 pg_member
- 删除新建的两张表
---
## 十一、风险评估
| 风险 | 等级 | 应对措施 |
|------|------|----------|
| 数据迁移丢失 | 高 | 完整备份,测试环境验证 |
| 接口不兼容 | 高 | 前后端同步上线 |
| 性能问题 | 低 | 新表已加索引 |
| 业务中断 | 中 | 选择低峰期上线 |
---
## 附录:现有数据统计(上线前执行)
```sql
-- 统计需要迁移的教育数据
SELECT COUNT(*) FROM pg_member
WHERE identity_type = '2' AND school_id IS NOT NULL AND del_flag = '0';
-- 统计有班级信息的数据
SELECT COUNT(*) FROM pg_member
WHERE identity_type = '2' AND school_class_id IS NOT NULL AND del_flag = '0';
```

View File

@ -0,0 +1,533 @@
# 教师多班级功能技术方案
> 作者:湖北新华业务中台研发团队
> 创建时间2026-02-03
> 状态:待审核
---
## 一、需求描述
### 1.1 业务场景
- 张老师是武汉二中高二年级的数学老师
- 他同时教 1班、2班、3班
- 系统应支持在**一个教育身份**中选择**多个班级**
### 1.2 预期效果
**添加/编辑教育身份时**
```
所在地区: 湖北省 / 武汉市 / 硚口区
学校: 武汉市第二中学
年级: 高二
班级: [1班] [2班] [3班] ← 多选
学科: 数学
```
**个人中心展示**
```
┌────────────────────────────────┐
│ 📘 数学 当前使用 │
│ 武汉市第二中学 │
│ 班级: 高二1班、高二2班、高二3班 │
│ 学科: 数学 │
│ 编辑 解除 切换 │
└────────────────────────────────┘
```
---
## 二、当前架构分析
### 2.1 现有数据结构
| 表名 | 字段 | 类型 | 说明 |
|------|------|------|------|
| `pg_member` | `school_class_id` | `bigint` | 单值,只能存一个班级 |
### 2.2 现有接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/h5/member/education` | GET | 返回单个教育身份 |
| `/h5/member/education` | POST | 保存单个教育身份(覆盖) |
| `/h5/member/education` | DELETE | 删除教育身份 |
### 2.3 问题
- 班级字段是单值,不支持多班级
- 接口设计为覆盖式更新
---
## 三、技术方案
### 3.1 数据库改造
#### 3.1.1 新建关联表
```sql
-- 会员-班级关联表(多对多)
CREATE TABLE pg_member_class (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
member_id BIGINT NOT NULL COMMENT '会员ID',
school_class_id BIGINT NOT NULL COMMENT '学校班级关联ID',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_member_class (member_id, school_class_id),
KEY idx_member_id (member_id)
) COMMENT '会员班级关联表';
```
#### 3.1.2 数据迁移脚本
```sql
-- 将现有数据迁移到关联表
INSERT INTO pg_member_class (member_id, school_class_id)
SELECT member_id, school_class_id
FROM pg_member
WHERE school_class_id IS NOT NULL AND identity_type = '2';
```
#### 3.1.3 pg_member 表调整
| 字段 | 处理方式 |
|------|----------|
| `school_class_id` | 保留(向后兼容)或后续版本删除 |
---
### 3.2 后端改造
#### 3.2.1 新建实体类
**文件**`ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/member/domain/PgMemberClass.java`
```java
package org.dromara.pangu.member.domain;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
@Data
@TableName("pg_member_class")
public class PgMemberClass {
@TableId(type = IdType.AUTO)
private Long id;
private Long memberId;
private Long schoolClassId;
private Date createTime;
}
```
#### 3.2.2 新建 Mapper
**文件**`ruoyi-modules/pangu-business/src/main/java/org/dromara/pangu/member/mapper/PgMemberClassMapper.java`
```java
package org.dromara.pangu.member.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.dromara.pangu.member.domain.PgMemberClass;
@Mapper
public interface PgMemberClassMapper extends BaseMapper<PgMemberClass> {
}
```
#### 3.2.3 修改 DTO
**文件**`H5EducationDto.java`
```java
// 修改前
@Schema(description = "学校班级关联ID")
@NotNull(message = "请选择班级")
private Long schoolClassId;
// 修改后
@Schema(description = "学校班级关联ID列表")
@NotEmpty(message = "请选择班级")
private List<Long> schoolClassIds;
```
#### 3.2.4 修改 VO
**文件**`H5EducationVo.java`
```java
// 新增字段
private List<Long> schoolClassIds; // 班级ID列表
private List<String> classNames; // 班级名称列表(用于前端展示)
```
#### 3.2.5 修改 Service
**文件**`H5MemberServiceImpl.java`
```java
@Autowired
private PgMemberClassMapper memberClassMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void saveEducation(H5EducationDto dto) {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
// 1. 校验学校、年级存在
// ... 原有校验逻辑 ...
// 2. 校验所有班级是否存在且属于该年级
for (Long classId : dto.getSchoolClassIds()) {
PgSchoolClass schoolClass = schoolClassMapper.selectById(classId);
if (schoolClass == null || !schoolClass.getSchoolGradeId().equals(dto.getSchoolGradeId())) {
throw new ServiceException("班级不存在或不属于该年级");
}
}
// 3. 更新会员基本信息
member.setSchoolId(dto.getSchoolId());
member.setSchoolGradeId(dto.getSchoolGradeId());
member.setSubjectId(dto.getSubjectId());
member.setIdentityType("2");
member.setRegionId(school.getRegionId());
memberMapper.updateById(member);
// 4. 删除旧的班级关联
memberClassMapper.delete(
new LambdaQueryWrapper<PgMemberClass>()
.eq(PgMemberClass::getMemberId, memberId)
);
// 5. 插入新的班级关联
for (Long classId : dto.getSchoolClassIds()) {
PgMemberClass mc = new PgMemberClass();
mc.setMemberId(memberId);
mc.setSchoolClassId(classId);
memberClassMapper.insert(mc);
}
}
@Override
public H5EducationVo getEducation() {
Long memberId = getCurrentMemberId();
PgMember member = memberMapper.selectById(memberId);
if (member == null) {
throw new ServiceException("会员不存在");
}
if (!"2".equals(member.getIdentityType()) || member.getSchoolId() == null) {
return null;
}
H5EducationVo vo = buildEducationVo(member);
// 查询关联的班级列表
List<PgMemberClass> memberClasses = memberClassMapper.selectList(
new LambdaQueryWrapper<PgMemberClass>()
.eq(PgMemberClass::getMemberId, memberId)
);
List<Long> classIds = memberClasses.stream()
.map(PgMemberClass::getSchoolClassId)
.collect(Collectors.toList());
vo.setSchoolClassIds(classIds);
// 填充班级名称
if (!classIds.isEmpty()) {
List<String> classNames = new ArrayList<>();
for (Long classId : classIds) {
PgSchoolClass schoolClass = schoolClassMapper.selectById(classId);
if (schoolClass != null) {
PgClass pgClass = classMapper.selectById(schoolClass.getClassId());
if (pgClass != null) {
classNames.add(pgClass.getClassName());
}
}
}
vo.setClassNames(classNames);
}
return vo;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteEducation() {
Long memberId = getCurrentMemberId();
// 删除班级关联
memberClassMapper.delete(
new LambdaQueryWrapper<PgMemberClass>()
.eq(PgMemberClass::getMemberId, memberId)
);
// 清空会员教育信息
// ... 原有逻辑 ...
}
```
---
### 3.3 H5 前端改造
#### 3.3.1 修改表单组件
**文件**`user_authentication_center_front/user-front/src/components/TeacherIdentityForm.vue`
**表单数据修改**
```javascript
// 修改前
const teacherForm = reactive({
classId: '',
})
// 修改后
const teacherForm = reactive({
classIds: [],
})
```
**模板修改**
```vue
<!-- 修改前 -->
<el-form-item label="班级" prop="classId">
<el-select v-model="teacherForm.classId">
<!-- ... -->
</el-select>
</el-form-item>
<!-- 修改后 -->
<el-form-item label="班级" prop="classIds">
<el-select
v-model="teacherForm.classIds"
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="3"
placeholder="请选择班级(可多选)"
size="large"
>
<el-option
v-for="classItem in classOptions"
:key="classItem.schoolClassId"
:label="classItem.className"
:value="classItem.schoolClassId"
/>
</el-select>
</el-form-item>
```
**验证规则修改**
```javascript
// 修改前
classId: [{ required: true, message: '请选择班级', trigger: 'change' }],
// 修改后
classIds: [{
required: true,
type: 'array',
min: 1,
message: '请至少选择一个班级',
trigger: 'change'
}],
```
**提交逻辑修改**
```javascript
const requestData = {
schoolId: teacherForm.schoolId,
schoolGradeId: teacherForm.gradeId,
schoolClassIds: teacherForm.classIds, // 数组
subjectId: teacherForm.subjectId,
}
```
**回显逻辑修改**
```javascript
// setFormData 中
teacherForm.classIds = formData.schoolClassIds || []
```
#### 3.3.2 修改列表展示
**文件**`user_authentication_center_front/user-front/src/views/userCenter/index.vue`
```javascript
// getEducationIdentities 方法中
educationIdentities.value = [{
id: 1,
role: `${item.subjectName || '教师'}`,
school: item.schoolName || '',
icon: 'mdi:account-tie',
active: true,
details: [
{ label: '班级', value: item.classNames?.join('、') || '' }, // 多班级用顿号连接
{ label: '学科', value: item.subjectName || '' },
],
}]
```
---
## 四、文件改动清单
| 层级 | 文件路径 | 改动类型 |
|------|----------|----------|
| **数据库** | DDL 脚本 | 新建 |
| **后端** | `pangu-business/.../member/domain/PgMemberClass.java` | 新建 |
| **后端** | `pangu-business/.../member/mapper/PgMemberClassMapper.java` | 新建 |
| **后端** | `pangu-business/.../h5/domain/dto/H5EducationDto.java` | 修改 |
| **后端** | `pangu-business/.../h5/domain/vo/H5EducationVo.java` | 修改 |
| **后端** | `pangu-business/.../h5/service/impl/H5MemberServiceImpl.java` | 修改 |
| **H5前端** | `user-front/src/components/TeacherIdentityForm.vue` | 修改 |
| **H5前端** | `user-front/src/views/userCenter/index.vue` | 修改 |
---
## 五、接口变更
### 5.1 POST /h5/member/education
**请求参数变更**
```json
// 修改前
{
"schoolId": 1,
"schoolGradeId": 5,
"schoolClassId": 104,
"subjectId": 1
}
// 修改后
{
"schoolId": 1,
"schoolGradeId": 5,
"schoolClassIds": [104, 105, 106],
"subjectId": 1
}
```
### 5.2 GET /h5/member/education
**响应参数变更**
```json
// 修改前
{
"code": 200,
"data": {
"schoolId": 1,
"schoolName": "武汉市第二中学",
"schoolGradeId": 5,
"gradeName": "高二",
"schoolClassId": 104,
"className": "1班",
"subjectId": 1,
"subjectName": "数学"
}
}
// 修改后
{
"code": 200,
"data": {
"schoolId": 1,
"schoolName": "武汉市第二中学",
"schoolGradeId": 5,
"gradeName": "高二",
"schoolClassIds": [104, 105, 106],
"classNames": ["1班", "2班", "3班"],
"subjectId": 1,
"subjectName": "数学"
}
}
```
---
## 六、测试要点
| 场景 | 测试内容 | 预期结果 |
|------|----------|----------|
| 新增 | 选择多个班级保存 | 保存成功,关联表有多条记录 |
| 编辑 | 回显多个班级 | 多选框显示已选班级 |
| 编辑 | 增加/减少班级 | 关联表正确更新 |
| 删除 | 删除教育身份 | 关联表数据同步删除 |
| 展示 | 列表显示多班级 | 显示"1班、2班、3班" |
| 边界 | 不选班级提交 | 校验失败,提示选择班级 |
| 边界 | 选择不同年级的班级 | 校验失败,提示班级不属于该年级 |
---
## 七、上线计划
| 步骤 | 内容 | 负责人 | 备注 |
|------|------|--------|------|
| 1 | 执行数据库 DDL | DBA | 建表 |
| 2 | 执行数据迁移脚本 | DBA | 迁移现有数据 |
| 3 | 部署后端服务 | 运维 | - |
| 4 | 部署 H5 前端 | 运维 | 需同步上线 |
| 5 | 验证测试 | 测试 | 回归测试 |
---
## 八、回滚方案
1. 后端代码回滚到上一版本
2. 前端代码回滚到上一版本
3. 数据库:
- 从 `pg_member_class` 恢复 `pg_member.school_class_id`(取第一条)
- 删除 `pg_member_class`
---
## 九、风险评估
| 风险 | 等级 | 应对措施 |
|------|------|----------|
| 数据迁移失败 | 中 | 先在测试环境验证,生产环境备份 |
| 前后端不同步 | 高 | 同步上线,灰度发布 |
| 性能问题 | 低 | 关联表已加索引 |
| 兼容性问题 | 中 | 管理后台如有相关页面需同步修改 |
---
## 附录:相关表结构
### pg_member会员表现有
| 字段 | 类型 | 说明 |
|------|------|------|
| member_id | bigint | 主键 |
| consumer_id | bigint | 用户ID |
| school_id | bigint | 学校ID |
| school_grade_id | bigint | 年级ID |
| school_class_id | bigint | 班级ID单值改造后可废弃 |
| subject_id | bigint | 学科ID |
| identity_type | varchar | 身份类型1-家长2-教师 |
### pg_member_class会员班级关联表新建
| 字段 | 类型 | 说明 |
|------|------|------|
| id | bigint | 主键 |
| member_id | bigint | 会员ID |
| school_class_id | bigint | 学校班级关联ID |
| create_time | datetime | 创建时间 |

View File

@ -0,0 +1,347 @@
<!--
教育身份编辑弹窗
@author 湖北新华业务中台研发团队
-->
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑教育身份' : '添加教育身份'"
width="550px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
>
<el-form-item label="区域" prop="regionId">
<el-cascader
v-model="regionIds"
:options="regionTree"
:props="{ value: 'regionId', label: 'regionName', checkStrictly: true }"
placeholder="请选择区域"
clearable
style="width: 100%"
@change="handleRegionChange"
/>
</el-form-item>
<el-form-item label="学校" prop="schoolId">
<el-select v-model="form.schoolId" placeholder="请选择学校" clearable style="width: 100%" @change="handleSchoolChange">
<el-option v-for="item in schoolList" :key="item.schoolId" :label="item.schoolName" :value="item.schoolId" />
</el-select>
</el-form-item>
<el-form-item label="年级" prop="schoolGradeId">
<el-select v-model="form.schoolGradeId" placeholder="请选择年级" clearable style="width: 100%" @change="handleGradeChange">
<el-option v-for="item in gradeList" :key="item.id" :label="item.gradeName" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="班级" prop="schoolClassId">
<el-select v-model="form.schoolClassId" placeholder="请选择班级" clearable style="width: 100%">
<el-option v-for="item in classList" :key="item.id" :label="item.className" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="学科" prop="subjectId">
<el-select v-model="form.subjectId" placeholder="请选择学科(可选)" clearable style="width: 100%">
<el-option v-for="item in subjectList" :key="item.subjectId" :label="item.subjectName" :value="item.subjectId" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import request from '@/utils/request'
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
const emit = defineEmits(['success', 'add', 'update'])
const visible = ref(false)
const isEdit = ref(false)
const formRef = ref(null)
const submitLoading = ref(false)
// IDIDmemberId null
const memberId = ref(null)
const educationId = ref(null)
//
const localIndex = ref(null)
// ID
const regionIds = ref([])
//
const form = reactive({
regionId: null,
schoolId: null,
schoolGradeId: null,
schoolClassId: null,
subjectId: null
})
//
const rules = {
schoolId: [{ required: true, message: '请选择学校', trigger: 'change' }],
schoolGradeId: [{ required: true, message: '请选择年级', trigger: 'change' }],
schoolClassId: [{ required: true, message: '请选择班级', trigger: 'change' }]
}
//
const regionTree = ref([])
const schoolList = ref([])
const gradeList = ref([])
const classList = ref([])
const subjectList = ref([])
/**
* 打开弹窗
* @param {Long} mId 会员IDnull 表示本地模式
* @param {Object} row 编辑时传入教育身份数据
* @param {Number} index 本地模式下的数组索引
*/
const open = async (mId, row, index) => {
resetForm()
memberId.value = mId
isEdit.value = !!row
localIndex.value = index ?? null
visible.value = true
//
await Promise.all([loadRegionTree(), loadSubjectList()])
//
if (row) {
educationId.value = row.educationId
form.schoolId = row.schoolId
form.schoolGradeId = row.schoolGradeId
form.schoolClassId = row.schoolClassId
form.subjectId = row.subjectId
//
if (row.regionId) {
// regionIds
if (row.regionIds) {
regionIds.value = row.regionIds
} else {
regionIds.value = await getRegionPath(row.regionId)
}
form.regionId = row.regionId
await loadSchoolList(row.regionId)
}
if (row.schoolId) {
await loadGradeList(row.schoolId)
}
if (row.schoolGradeId) {
await loadClassList(row.schoolGradeId)
}
}
}
/**
* 重置表单
*/
const resetForm = () => {
educationId.value = null
regionIds.value = []
form.regionId = null
form.schoolId = null
form.schoolGradeId = null
form.schoolClassId = null
form.subjectId = null
schoolList.value = []
gradeList.value = []
classList.value = []
}
/**
* 加载区域树
*/
const loadRegionTree = async () => {
try {
const res = await request.get('/business/region/tree')
regionTree.value = res.data || []
} catch (e) {
regionTree.value = []
}
}
/**
* 加载学科列表
*/
const loadSubjectList = async () => {
try {
const res = await request.get('/business/subject/listAll')
subjectList.value = res.data || []
} catch (e) {
subjectList.value = []
}
}
/**
* 加载学校列表
*/
const loadSchoolList = async (regionId) => {
try {
const res = await request.get('/business/school/listAll', { params: { regionId } })
schoolList.value = res.data || []
} catch (e) {
schoolList.value = []
}
}
/**
* 加载年级列表
*/
const loadGradeList = async (schoolId) => {
try {
const res = await request.get(`/business/school/${schoolId}/grades`)
gradeList.value = res.data || []
} catch (e) {
gradeList.value = []
}
}
/**
* 加载班级列表
*/
const loadClassList = async (schoolGradeId) => {
try {
const res = await request.get(`/business/school/grade/${schoolGradeId}/classes`)
classList.value = res.data || []
} catch (e) {
classList.value = []
}
}
/**
* 获取区域路径用于回显
*/
const getRegionPath = async (regionId) => {
try {
const res = await request.get(`/business/region/${regionId}/path`)
return res.data || []
} catch (e) {
return []
}
}
/**
* 区域变更
*/
const handleRegionChange = (val) => {
form.regionId = val && val.length ? val[val.length - 1] : null
form.schoolId = null
form.schoolGradeId = null
form.schoolClassId = null
schoolList.value = []
gradeList.value = []
classList.value = []
if (form.regionId) {
loadSchoolList(form.regionId)
}
}
/**
* 学校变更
*/
const handleSchoolChange = () => {
form.schoolGradeId = null
form.schoolClassId = null
gradeList.value = []
classList.value = []
if (form.schoolId) {
loadGradeList(form.schoolId)
}
}
/**
* 年级变更
*/
const handleGradeChange = () => {
form.schoolClassId = null
classList.value = []
if (form.schoolGradeId) {
loadClassList(form.schoolGradeId)
}
}
/**
* 提交表单
*/
const handleSubmit = async () => {
try {
await formRef.value?.validate()
} catch (e) {
return
}
//
const data = {
regionId: form.regionId,
regionIds: [...regionIds.value],
schoolId: form.schoolId,
schoolGradeId: form.schoolGradeId,
schoolClassId: form.schoolClassId,
subjectId: form.subjectId,
//
schoolName: schoolList.value.find(s => s.schoolId === form.schoolId)?.schoolName || '',
gradeName: gradeList.value.find(g => g.id === form.schoolGradeId)?.gradeName || '',
className: classList.value.find(c => c.id === form.schoolClassId)?.className || '',
subjectName: subjectList.value.find(s => s.subjectId === form.subjectId)?.subjectName || ''
}
//
if (!memberId.value) {
visible.value = false
if (isEdit.value) {
emit('update', data, localIndex.value)
} else {
emit('add', data)
}
return
}
// API
submitLoading.value = true
try {
const apiData = {
regionId: form.regionId,
schoolId: form.schoolId,
schoolGradeId: form.schoolGradeId,
schoolClassId: form.schoolClassId,
subjectId: form.subjectId
}
if (isEdit.value) {
const res = await request.put(`/business/member/${memberId.value}/educations/${educationId.value}`, apiData)
if (res.code === 200) {
ElMessage.success('修改成功')
visible.value = false
emit('success')
}
} else {
const res = await request.post(`/business/member/${memberId.value}/educations`, apiData)
if (res.code === 200) {
ElMessage.success('添加成功')
visible.value = false
emit('success')
}
}
} finally {
submitLoading.value = false
}
}
defineExpose({ open })
</script>

View File

@ -53,14 +53,6 @@
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="身份类型" prop="identityType">
<el-radio-group v-model="form.identityType" @change="handleIdentityChange">
<el-radio value="1">家长</el-radio>
<el-radio value="2">教师</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-switch
@ -74,65 +66,45 @@
</el-col>
</el-row>
<!-- 教师身份时显示学校信息 -->
<template v-if="form.identityType === '2'">
<el-divider content-position="left">学校信息教师必填</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="区域" prop="regionId" :rules="teacherRules.regionId">
<el-cascader
v-model="form.regionIds"
:options="regionTree"
:props="{ value: 'regionId', label: 'regionName', checkStrictly: true }"
placeholder="请选择区域"
clearable
style="width: 100%"
@change="handleRegionChange"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="学校" prop="schoolId" :rules="teacherRules.schoolId">
<el-select v-model="form.schoolId" placeholder="请选择学校" clearable style="width: 100%" @change="handleSchoolChange">
<el-option v-for="item in schoolList" :key="item.schoolId" :label="item.schoolName" :value="item.schoolId" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="年级" prop="schoolGradeId" :rules="teacherRules.schoolGradeId">
<el-select v-model="form.schoolGradeId" placeholder="请选择年级" clearable style="width: 100%" @change="handleGradeChange">
<el-option v-for="item in gradeList" :key="item.id" :label="item.gradeName" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="班级" prop="schoolClassId" :rules="teacherRules.schoolClassId">
<el-select v-model="form.schoolClassId" placeholder="请选择班级" clearable style="width: 100%">
<el-option v-for="item in classList" :key="item.id" :label="item.className" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
</el-row>
</template>
<!-- 绑定学生 -->
<el-divider content-position="left">绑定学生</el-divider>
<el-alert
v-if="form.identityType === '2'"
title="教师只能绑定本校学生"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<!-- 教育身份 -->
<el-divider content-position="left">教育身份</el-divider>
<el-row style="margin-bottom: 12px;">
<el-button type="primary" size="small" :icon="Plus" @click="handleAddStudent">添加学生</el-button>
<el-button type="primary" size="small" :icon="Plus" @click="handleAddEducation">添加教育身份</el-button>
</el-row>
<el-table :data="form.students" border size="small" max-height="200">
<el-table :data="educations" border size="small" max-height="180">
<template #empty>
<el-empty description="暂无绑定学生" :image-size="60" />
<el-empty description="暂无教育身份" :image-size="60" />
</template>
<el-table-column prop="schoolName" label="学校" min-width="140" show-overflow-tooltip />
<el-table-column prop="gradeName" label="年级" width="70" />
<el-table-column prop="className" label="班级" width="60" />
<el-table-column prop="subjectName" label="学科" width="60" />
<el-table-column label="默认" width="60" align="center">
<template #default="{ row }">
<el-tag v-if="row.isDefault === '1'" type="success" size="small">默认</el-tag>
<el-button v-else link type="primary" size="small" @click="handleSetDefault(row)">设为</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEditEducation(row)">编辑</el-button>
<el-popconfirm title="确定删除该教育身份?" @confirm="handleDeleteEducation(row)">
<template #reference>
<el-button link type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 亲子关系 -->
<el-divider content-position="left">亲子关系</el-divider>
<el-row style="margin-bottom: 12px;">
<el-button type="primary" size="small" :icon="Plus" @click="handleAddStudent">添加亲子关系</el-button>
</el-row>
<el-table :data="form.students" border size="small" max-height="180">
<template #empty>
<el-empty description="暂无亲子关系" :image-size="60" />
</template>
<el-table-column prop="studentName" label="姓名" min-width="80" />
<el-table-column prop="studentNo" label="学号" width="120" />
@ -141,9 +113,9 @@
<el-table-column prop="className" label="班级" width="60" />
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-popconfirm title="确定解绑该学生?" @confirm="handleRemoveStudent(row)">
<el-popconfirm :title="isEdit ? '确定解绑该学生?' : '确定移除该学生?'" @confirm="handleRemoveStudent(row)">
<template #reference>
<el-button link type="danger" size="small">解绑</el-button>
<el-button link type="danger" size="small">{{ isEdit ? '解绑' : '移除' }}</el-button>
</template>
</el-popconfirm>
</template>
@ -156,7 +128,10 @@
</template>
<!-- 学生选择器 -->
<StudentSelectDialog ref="studentSelectRef" @success="loadBoundStudents" />
<StudentSelectDialog ref="studentSelectRef" @success="loadBoundStudents" @add="handleLocalAddStudents" />
<!-- 教育身份编辑弹窗 -->
<EducationDialog ref="educationDialogRef" @success="loadEducations" @add="handleLocalAddEducation" @update="handleLocalUpdateEducation" />
</el-dialog>
</template>
@ -166,6 +141,7 @@ import { Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
import StudentSelectDialog from './StudentSelectDialog.vue'
import EducationDialog from './EducationDialog.vue'
const emit = defineEmits(['success'])
@ -174,6 +150,10 @@ const isEdit = ref(false)
const formRef = ref(null)
const submitLoading = ref(false)
const studentSelectRef = ref(null)
const educationDialogRef = ref(null)
//
const educations = ref([])
//
const form = reactive({
@ -182,13 +162,7 @@ const form = reactive({
nickname: '',
gender: '0',
birthday: '',
identityType: '1',
status: '0',
regionIds: [],
regionId: null,
schoolId: null,
schoolGradeId: null,
schoolClassId: null,
students: []
})
@ -197,26 +171,9 @@ const rules = {
phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
],
identityType: [
{ required: true, message: '请选择身份类型', trigger: 'change' }
]
}
//
const teacherRules = {
regionId: [{ required: true, message: '请选择所属区域', trigger: 'change' }],
schoolId: [{ required: true, message: '请选择所属学校', trigger: 'change' }],
schoolGradeId: [{ required: true, message: '请选择所属年级', trigger: 'change' }],
schoolClassId: [{ required: true, message: '请选择所属班级', trigger: 'change' }]
}
//
const regionTree = ref([])
const schoolList = ref([])
const gradeList = ref([])
const classList = ref([])
/**
* 打开弹窗
*/
@ -225,28 +182,14 @@ const open = async (row) => {
isEdit.value = !!row
visible.value = true
//
await loadRegionTree()
//
if (row && row.memberId) {
try {
const res = await request.get(`/business/member/${row.memberId}`)
if (res.code === 200 && res.data) {
Object.assign(form, res.data)
// regionIds
//
if (form.regionId) {
await loadSchoolList(form.regionId)
}
if (form.schoolId) {
await loadGradeList(form.schoolId)
}
if (form.schoolGradeId) {
await loadClassList(form.schoolGradeId)
}
//
await loadBoundStudents()
//
await Promise.all([loadEducations(), loadBoundStudents()])
}
} catch (e) {
console.error('加载会员数据失败', e)
@ -254,6 +197,22 @@ const open = async (row) => {
}
}
/**
* 加载教育身份列表
*/
const loadEducations = async () => {
if (!form.memberId) {
educations.value = []
return
}
try {
const res = await request.get(`/business/member/${form.memberId}/educations`)
educations.value = res.data || []
} catch (e) {
educations.value = []
}
}
/**
* 加载已绑定的学生列表
*/
@ -279,143 +238,147 @@ const resetForm = () => {
form.nickname = ''
form.gender = '0'
form.birthday = ''
form.identityType = '1'
form.status = '0'
form.regionIds = []
form.regionId = null
form.schoolId = null
form.schoolGradeId = null
form.schoolClassId = null
form.students = []
schoolList.value = []
gradeList.value = []
classList.value = []
educations.value = []
}
/**
* 加载区域树
* 添加教育身份
*/
const loadRegionTree = async () => {
try {
const res = await request.get('/business/region/tree')
regionTree.value = res.data || []
} catch (e) {
regionTree.value = []
const handleAddEducation = () => {
// memberId null
// memberId
educationDialogRef.value?.open(isEdit.value ? form.memberId : null)
}
/**
* 编辑教育身份
*/
const handleEditEducation = (row) => {
const index = educations.value.indexOf(row)
educationDialogRef.value?.open(isEdit.value ? form.memberId : null, row, index)
}
/**
* 本地添加教育身份新增会员模式
*/
const handleLocalAddEducation = (data) => {
//
if (educations.value.length === 0) {
data.isDefault = '1'
} else {
data.isDefault = '0'
}
educations.value.push(data)
}
/**
* 本地更新教育身份新增会员模式
*/
const handleLocalUpdateEducation = (data, index) => {
if (index !== null && index >= 0) {
//
data.isDefault = educations.value[index].isDefault
educations.value[index] = data
}
}
/**
* 加载学校列表
* 删除教育身份
*/
const loadSchoolList = async (regionId) => {
try {
const res = await request.get('/business/school/listAll', { params: { regionId } })
schoolList.value = res.data || []
} catch (e) {
schoolList.value = []
}
}
/**
* 加载年级列表
*/
const loadGradeList = async (schoolId) => {
try {
const res = await request.get(`/business/school/${schoolId}/grades`)
gradeList.value = res.data || []
} catch (e) {
gradeList.value = []
}
}
/**
* 加载班级列表
*/
const loadClassList = async (schoolGradeId) => {
try {
const res = await request.get(`/business/school/grade/${schoolGradeId}/classes`)
classList.value = res.data || []
} catch (e) {
classList.value = []
}
}
/**
* 身份类型变更
*/
const handleIdentityChange = () => {
//
if (form.identityType === '1') {
form.regionIds = []
form.regionId = null
form.schoolId = null
form.schoolGradeId = null
form.schoolClassId = null
schoolList.value = []
gradeList.value = []
classList.value = []
}
}
/**
* 区域变更
*/
const handleRegionChange = (val) => {
form.regionId = val && val.length ? val[val.length - 1] : null
form.schoolId = null
form.schoolGradeId = null
form.schoolClassId = null
schoolList.value = []
gradeList.value = []
classList.value = []
if (form.regionId) {
loadSchoolList(form.regionId)
}
}
/**
* 学校变更
*/
const handleSchoolChange = () => {
form.schoolGradeId = null
form.schoolClassId = null
gradeList.value = []
classList.value = []
if (form.schoolId) {
loadGradeList(form.schoolId)
}
}
/**
* 年级变更
*/
const handleGradeChange = () => {
form.schoolClassId = null
classList.value = []
if (form.schoolGradeId) {
loadClassList(form.schoolGradeId)
}
}
/**
* 添加学生
*/
const handleAddStudent = () => {
if (!isEdit.value || !form.memberId) {
ElMessage.warning('请先保存会员信息后再绑定学生')
const handleDeleteEducation = async (row) => {
//
if (!isEdit.value) {
const index = educations.value.indexOf(row)
if (index > -1) {
educations.value.splice(index, 1)
//
if (row.isDefault === '1' && educations.value.length > 0) {
educations.value[0].isDefault = '1'
}
}
return
}
studentSelectRef.value?.open({
memberId: form.memberId,
identityType: form.identityType,
schoolId: form.schoolId
})
//
try {
const res = await request.delete(`/business/member/${form.memberId}/educations/${row.educationId}`)
if (res.code === 200) {
ElMessage.success('删除成功')
await loadEducations()
}
} catch (e) {
console.error('删除失败', e)
}
}
/**
* 解绑学生
* 设为默认教育身份
*/
const handleSetDefault = async (row) => {
//
if (!isEdit.value) {
educations.value.forEach(e => e.isDefault = '0')
row.isDefault = '1'
return
}
//
try {
const res = await request.put(`/business/member/${form.memberId}/educations/${row.educationId}/default`)
if (res.code === 200) {
ElMessage.success('设置成功')
await loadEducations()
}
} catch (e) {
console.error('设置失败', e)
}
}
/**
* 添加亲子关系
*/
const handleAddStudent = () => {
// excludeIds
// memberId
if (isEdit.value) {
studentSelectRef.value?.open({
memberId: form.memberId
})
} else {
studentSelectRef.value?.open({
excludeIds: form.students.map(s => s.studentId)
})
}
}
/**
* 本地添加学生新增会员模式
*/
const handleLocalAddStudents = (students) => {
//
for (const student of students) {
if (!form.students.some(s => s.studentId === student.studentId)) {
form.students.push(student)
}
}
}
/**
* 解绑/移除学生
*/
const handleRemoveStudent = async (row) => {
//
if (!isEdit.value) {
const index = form.students.findIndex(s => s.studentId === row.studentId)
if (index > -1) {
form.students.splice(index, 1)
}
return
}
//
try {
const res = await request.post(`/business/member/unbindStudent/${row.studentId}`)
if (res.code === 200) {
@ -439,10 +402,14 @@ const handleSubmit = async () => {
submitLoading.value = true
try {
const data = { ...form }
//
delete data.regionIds
delete data.students
const data = {
memberId: form.memberId,
phone: form.phone,
nickname: form.nickname,
gender: form.gender,
birthday: form.birthday,
status: form.status
}
if (isEdit.value) {
const res = await request.put('/business/member', data)
@ -452,6 +419,15 @@ const handleSubmit = async () => {
emit('success')
}
} else {
//
data.educations = educations.value.map(e => ({
regionId: e.regionId,
schoolId: e.schoolId,
schoolGradeId: e.schoolGradeId,
schoolClassId: e.schoolClassId,
subjectId: e.subjectId
}))
data.studentIds = form.students.map(s => s.studentId)
const res = await request.post('/business/member', data)
if (res.code === 200) {
ElMessage.success('新增成功')

View File

@ -24,16 +24,6 @@
</el-form-item>
</el-form>
<!-- 教师身份提示 -->
<el-alert
v-if="isTeacher"
title="教师只能绑定本校学生"
type="info"
:closable="false"
show-icon
style="margin-bottom: 12px"
/>
<!-- 学生列表 -->
<el-table
ref="tableRef"
@ -74,7 +64,7 @@
<div class="dialog-footer">
<span style="margin-right: 16px; color: #909399">已选择 {{ selectedStudents.length }} 名学生</span>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleConfirm">确定绑定</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleConfirm">确定</el-button>
</div>
</template>
</el-dialog>
@ -86,7 +76,7 @@ import { Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { reactive, ref } from 'vue'
const emit = defineEmits(['success'])
const emit = defineEmits(['success', 'add'])
const visible = ref(false)
const loading = ref(false)
@ -96,10 +86,12 @@ const total = ref(0)
const selectedStudents = ref([])
const tableRef = ref(null)
//
// memberId null
const memberId = ref(null)
const isTeacher = ref(false)
const schoolId = ref(null)
// ID
const excludeStudentIds = ref([])
//
const queryParams = reactive({
@ -111,12 +103,13 @@ const queryParams = reactive({
/**
* 打开弹窗
* @param options { memberId: Long, identityType: String, schoolId: Long }
* @param options { memberId: Long, excludeIds: Long[] } - memberId null 表示本地模式
*/
const open = (options = {}) => {
memberId.value = options.memberId
isTeacher.value = options.identityType === '2'
schoolId.value = options.schoolId
memberId.value = options.memberId || null
isTeacher.value = false
schoolId.value = null
excludeStudentIds.value = options.excludeIds || []
resetQuery()
visible.value = true
@ -134,11 +127,15 @@ const getList = async () => {
pageSize: queryParams.pageSize,
studentName: queryParams.studentName || undefined,
studentNo: queryParams.studentNo || undefined,
memberId: memberId.value,
schoolId: isTeacher.value ? schoolId.value : undefined
memberId: memberId.value || undefined
}
const res = await request.get('/business/student/available', { params })
studentList.value = res.rows || []
let list = res.rows || []
//
if (!memberId.value && excludeStudentIds.value.length > 0) {
list = list.filter(s => !excludeStudentIds.value.includes(s.studentId))
}
studentList.value = list
total.value = res.total || 0
} catch (e) {
console.error('获取学生列表失败', e)
@ -195,11 +192,18 @@ const handleSelectionChange = (selection) => {
*/
const handleConfirm = async () => {
if (selectedStudents.value.length === 0) {
ElMessage.warning('请选择要绑定的学生')
ElMessage.warning('请选择要添加的学生')
return
}
//
//
if (!memberId.value) {
visible.value = false
emit('add', selectedStudents.value)
return
}
// API
const studentIds = selectedStudents.value
.filter(s => s.memberId !== memberId.value)
.map(s => s.studentId)

View File

@ -9,12 +9,6 @@
<el-form-item label="昵称">
<el-input v-model="queryParams.nickname" placeholder="请输入昵称" clearable style="width: 150px" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="身份类型">
<el-select v-model="queryParams.identityType" placeholder="全部" clearable style="width: 120px">
<el-option label="家长" value="1" />
<el-option label="教师" value="2" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部" clearable style="width: 100px">
<el-option label="正常" value="0" />
@ -72,13 +66,6 @@
{{ formatBirthday(row.birthday) }}
</template>
</el-table-column>
<el-table-column prop="identityType" label="身份类型" width="85" align="center">
<template #default="{ row }">
<el-tag :type="row.identityType === '1' ? 'success' : 'warning'">
{{ row.identityType === '1' ? '家长' : '教师' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="registerTime" label="注册时间" width="165" />
<el-table-column prop="registerSource" label="注册来源" width="85" align="center">
<template #default="{ row }">
@ -157,7 +144,6 @@ const queryParams = ref({
pageSize: 10,
phone: '',
nickname: '',
identityType: '',
status: ''
})
@ -235,7 +221,6 @@ const resetQuery = () => {
pageSize: 10,
phone: '',
nickname: '',
identityType: '',
status: ''
}
dateRange.value = []