feat: 支持账号导入与强制改密
- 新增账号导入模板下载、导入校验和默认密码重置标记 - 支持管理员重置密码并在登录后强制跳转修改密码 - 管理端与用户中心接入强密码校验和密码重置流程
This commit is contained in:
@@ -1,27 +1,32 @@
|
|||||||
package tech.easyflow.admin.controller.system;
|
package tech.easyflow.admin.controller.system;
|
||||||
|
|
||||||
import tech.easyflow.common.constant.enums.EnumAccountType;
|
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||||
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
|
||||||
import tech.easyflow.common.domain.Result;
|
|
||||||
import tech.easyflow.common.entity.LoginAccount;
|
|
||||||
import tech.easyflow.common.util.StringUtil;
|
|
||||||
|
|
||||||
import tech.easyflow.common.web.controller.BaseCurdController;
|
|
||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
|
||||||
import tech.easyflow.log.annotation.LogRecord;
|
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
|
||||||
import tech.easyflow.system.service.SysAccountService;
|
|
||||||
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
|
||||||
import cn.dev33.satoken.stp.StpUtil;
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
import cn.hutool.crypto.digest.BCrypt;
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
import com.mybatisflex.core.paginate.Page;
|
import com.mybatisflex.core.paginate.Page;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumAccountType;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||||
|
import tech.easyflow.common.domain.Result;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.satoken.util.SaTokenUtil;
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
|
import tech.easyflow.common.web.controller.BaseCurdController;
|
||||||
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
|
import tech.easyflow.log.annotation.LogRecord;
|
||||||
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
|
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||||
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
import tech.easyflow.system.util.SysPasswordPolicy;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
@@ -62,13 +67,21 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
|||||||
return Result.fail(1, "用户名已存在");
|
return Result.fail(1, "用户名已存在");
|
||||||
}
|
}
|
||||||
String password = entity.getPassword();
|
String password = entity.getPassword();
|
||||||
if (StringUtil.hasText(password)) {
|
if (!StringUtil.hasText(password)) {
|
||||||
entity.setPassword(BCrypt.hashpw(password));
|
return Result.fail(1, "密码不能为空");
|
||||||
}
|
}
|
||||||
|
SysPasswordPolicy.validateStrongPassword(password);
|
||||||
|
entity.setPassword(BCrypt.hashpw(password));
|
||||||
Integer status = entity.getStatus();
|
Integer status = entity.getStatus();
|
||||||
if (status == null) {
|
if (status == null) {
|
||||||
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
|
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
|
||||||
}
|
}
|
||||||
|
if (entity.getAccountType() == null) {
|
||||||
|
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||||
|
}
|
||||||
|
if (entity.getPasswordResetRequired() == null) {
|
||||||
|
entity.setPasswordResetRequired(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
SysAccount record = service.getById(entity.getId());
|
SysAccount record = service.getById(entity.getId());
|
||||||
// 如果修改了部门,就将用户踢下线,避免用户操作数据造成数据错误
|
// 如果修改了部门,就将用户踢下线,避免用户操作数据造成数据错误
|
||||||
@@ -149,15 +162,40 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
|
|||||||
if (!newPassword.equals(confirmPassword)) {
|
if (!newPassword.equals(confirmPassword)) {
|
||||||
return Result.fail(2, "两次密码不一致");
|
return Result.fail(2, "两次密码不一致");
|
||||||
}
|
}
|
||||||
|
SysPasswordPolicy.validateStrongPassword(newPassword);
|
||||||
SysAccount update = new SysAccount();
|
SysAccount update = new SysAccount();
|
||||||
update.setId(loginAccountId);
|
update.setId(loginAccountId);
|
||||||
update.setPassword(BCrypt.hashpw(newPassword));
|
update.setPassword(BCrypt.hashpw(newPassword));
|
||||||
|
update.setPasswordResetRequired(false);
|
||||||
update.setModified(new Date());
|
update.setModified(new Date());
|
||||||
update.setModifiedBy(loginAccountId);
|
update.setModifiedBy(loginAccountId);
|
||||||
service.updateById(update);
|
service.updateById(update);
|
||||||
return Result.ok();
|
return Result.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/resetPassword")
|
||||||
|
@SaCheckPermission("/api/v1/sysAccount/save")
|
||||||
|
public Result<Void> resetPassword(@JsonBody(value = "id", required = true) BigInteger id) {
|
||||||
|
service.resetPassword(id, SaTokenUtil.getLoginAccount().getId());
|
||||||
|
return Result.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/importExcel")
|
||||||
|
@SaCheckPermission("/api/v1/sysAccount/save")
|
||||||
|
public Result<SysAccountImportResultVo> importExcel(MultipartFile file) {
|
||||||
|
return Result.ok(service.importAccounts(file, SaTokenUtil.getLoginAccount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/downloadImportTemplate")
|
||||||
|
@SaCheckPermission("/api/v1/sysAccount/query")
|
||||||
|
public void downloadImportTemplate(HttpServletResponse response) throws Exception {
|
||||||
|
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
response.setCharacterEncoding("utf-8");
|
||||||
|
String fileName = URLEncoder.encode("user_import_template", "UTF-8").replaceAll("\\+", "%20");
|
||||||
|
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
|
||||||
|
service.writeImportTemplate(response.getOutputStream());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@PostMapping("save")
|
@PostMapping("save")
|
||||||
public Result<?> save(@JsonBody SysAccount entity) {
|
public Result<?> save(@JsonBody SysAccount entity) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
|
|||||||
import tech.easyflow.common.web.jsonbody.JsonBody;
|
import tech.easyflow.common.web.jsonbody.JsonBody;
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
import tech.easyflow.system.service.SysAccountService;
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
import tech.easyflow.system.util.SysPasswordPolicy;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
@@ -80,9 +81,11 @@ public class UcSysAccountController {
|
|||||||
if (!newPassword.equals(confirmPassword)) {
|
if (!newPassword.equals(confirmPassword)) {
|
||||||
return Result.fail(2, "两次密码不一致");
|
return Result.fail(2, "两次密码不一致");
|
||||||
}
|
}
|
||||||
|
SysPasswordPolicy.validateStrongPassword(newPassword);
|
||||||
SysAccount update = new SysAccount();
|
SysAccount update = new SysAccount();
|
||||||
update.setId(loginAccountId);
|
update.setId(loginAccountId);
|
||||||
update.setPassword(BCrypt.hashpw(newPassword));
|
update.setPassword(BCrypt.hashpw(newPassword));
|
||||||
|
update.setPasswordResetRequired(false);
|
||||||
update.setModified(new Date());
|
update.setModified(new Date());
|
||||||
update.setModifiedBy(loginAccountId);
|
update.setModifiedBy(loginAccountId);
|
||||||
service.updateById(update);
|
service.updateById(update);
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ public class LoginVO {
|
|||||||
*/
|
*/
|
||||||
private String avatar;
|
private String avatar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否强制修改密码
|
||||||
|
*/
|
||||||
|
private boolean forceChangePassword;
|
||||||
|
|
||||||
public String getToken() {
|
public String getToken() {
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -38,4 +43,12 @@ public class LoginVO {
|
|||||||
public void setAvatar(String avatar) {
|
public void setAvatar(String avatar) {
|
||||||
this.avatar = avatar;
|
this.avatar = avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isForceChangePassword() {
|
||||||
|
return forceChangePassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setForceChangePassword(boolean forceChangePassword) {
|
||||||
|
this.forceChangePassword = forceChangePassword;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
|
|||||||
res.setToken(StpUtil.getTokenValue());
|
res.setToken(StpUtil.getTokenValue());
|
||||||
res.setNickname(record.getNickname());
|
res.setNickname(record.getNickname());
|
||||||
res.setAvatar(record.getAvatar());
|
res.setAvatar(record.getAvatar());
|
||||||
|
res.setForceChangePassword(Boolean.TRUE.equals(record.getPasswordResetRequired()));
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ public class SysAccountBase extends DateEntity implements Serializable {
|
|||||||
@Column(comment = "密码")
|
@Column(comment = "密码")
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否需要重置密码
|
||||||
|
*/
|
||||||
|
@Column(comment = "是否需要重置密码")
|
||||||
|
private Boolean passwordResetRequired;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 账户类型
|
* 账户类型
|
||||||
*/
|
*/
|
||||||
@@ -149,6 +155,14 @@ public class SysAccountBase extends DateEntity implements Serializable {
|
|||||||
this.password = password;
|
this.password = password;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Boolean getPasswordResetRequired() {
|
||||||
|
return passwordResetRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPasswordResetRequired(Boolean passwordResetRequired) {
|
||||||
|
this.passwordResetRequired = passwordResetRequired;
|
||||||
|
}
|
||||||
|
|
||||||
public Integer getAccountType() {
|
public Integer getAccountType() {
|
||||||
return accountType;
|
return accountType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package tech.easyflow.system.entity.vo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户导入失败行。
|
||||||
|
*/
|
||||||
|
public class SysAccountImportErrorRowVo {
|
||||||
|
|
||||||
|
private Integer rowNumber;
|
||||||
|
|
||||||
|
private String deptCode;
|
||||||
|
|
||||||
|
private String loginName;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
|
||||||
|
public Integer getRowNumber() {
|
||||||
|
return rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRowNumber(Integer rowNumber) {
|
||||||
|
this.rowNumber = rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeptCode() {
|
||||||
|
return deptCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeptCode(String deptCode) {
|
||||||
|
this.deptCode = deptCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLoginName() {
|
||||||
|
return loginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginName(String loginName) {
|
||||||
|
this.loginName = loginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setReason(String reason) {
|
||||||
|
this.reason = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package tech.easyflow.system.entity.vo;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户导入结果。
|
||||||
|
*/
|
||||||
|
public class SysAccountImportResultVo {
|
||||||
|
|
||||||
|
private int successCount = 0;
|
||||||
|
|
||||||
|
private int errorCount = 0;
|
||||||
|
|
||||||
|
private int totalCount = 0;
|
||||||
|
|
||||||
|
private List<SysAccountImportErrorRowVo> errorRows = new ArrayList<>();
|
||||||
|
|
||||||
|
public int getSuccessCount() {
|
||||||
|
return successCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSuccessCount(int successCount) {
|
||||||
|
this.successCount = successCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getErrorCount() {
|
||||||
|
return errorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorCount(int errorCount) {
|
||||||
|
this.errorCount = errorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTotalCount() {
|
||||||
|
return totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTotalCount(int totalCount) {
|
||||||
|
this.totalCount = totalCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SysAccountImportErrorRowVo> getErrorRows() {
|
||||||
|
return errorRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setErrorRows(List<SysAccountImportErrorRowVo> errorRows) {
|
||||||
|
this.errorRows = errorRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
package tech.easyflow.system.service;
|
package tech.easyflow.system.service;
|
||||||
|
|
||||||
import com.mybatisflex.core.service.IService;
|
import com.mybatisflex.core.service.IService;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
|
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||||
|
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户表 服务层。
|
* 用户表 服务层。
|
||||||
@@ -14,4 +20,10 @@ public interface SysAccountService extends IService<SysAccount> {
|
|||||||
void syncRelations(SysAccount entity);
|
void syncRelations(SysAccount entity);
|
||||||
|
|
||||||
SysAccount getByUsername(String userKey);
|
SysAccount getByUsername(String userKey);
|
||||||
|
|
||||||
|
void resetPassword(BigInteger accountId, BigInteger operatorId);
|
||||||
|
|
||||||
|
SysAccountImportResultVo importAccounts(MultipartFile file, LoginAccount loginAccount);
|
||||||
|
|
||||||
|
void writeImportTemplate(OutputStream outputStream);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,54 @@
|
|||||||
package tech.easyflow.system.service.impl;
|
package tech.easyflow.system.service.impl;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
|
import cn.idev.excel.EasyExcel;
|
||||||
|
import cn.idev.excel.FastExcel;
|
||||||
|
import cn.idev.excel.context.AnalysisContext;
|
||||||
|
import cn.idev.excel.metadata.data.ReadCellData;
|
||||||
|
import cn.idev.excel.read.listener.ReadListener;
|
||||||
import com.mybatisflex.core.query.QueryWrapper;
|
import com.mybatisflex.core.query.QueryWrapper;
|
||||||
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
import com.mybatisflex.spring.service.impl.ServiceImpl;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import tech.easyflow.common.cache.RedisLockExecutor;
|
import tech.easyflow.common.cache.RedisLockExecutor;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumAccountType;
|
||||||
|
import tech.easyflow.common.constant.enums.EnumDataStatus;
|
||||||
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
import tech.easyflow.common.util.StringUtil;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
import tech.easyflow.system.entity.SysAccount;
|
import tech.easyflow.system.entity.SysAccount;
|
||||||
import tech.easyflow.system.entity.SysAccountPosition;
|
import tech.easyflow.system.entity.SysAccountPosition;
|
||||||
import tech.easyflow.system.entity.SysAccountRole;
|
import tech.easyflow.system.entity.SysAccountRole;
|
||||||
|
import tech.easyflow.system.entity.SysDept;
|
||||||
|
import tech.easyflow.system.entity.SysPosition;
|
||||||
|
import tech.easyflow.system.entity.SysRole;
|
||||||
|
import tech.easyflow.system.entity.vo.SysAccountImportErrorRowVo;
|
||||||
|
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
|
||||||
import tech.easyflow.system.mapper.SysAccountMapper;
|
import tech.easyflow.system.mapper.SysAccountMapper;
|
||||||
import tech.easyflow.system.mapper.SysAccountPositionMapper;
|
import tech.easyflow.system.mapper.SysAccountPositionMapper;
|
||||||
import tech.easyflow.system.mapper.SysAccountRoleMapper;
|
import tech.easyflow.system.mapper.SysAccountRoleMapper;
|
||||||
|
import tech.easyflow.system.mapper.SysDeptMapper;
|
||||||
|
import tech.easyflow.system.mapper.SysPositionMapper;
|
||||||
import tech.easyflow.system.mapper.SysRoleMapper;
|
import tech.easyflow.system.mapper.SysRoleMapper;
|
||||||
import tech.easyflow.system.service.SysAccountService;
|
import tech.easyflow.system.service.SysAccountService;
|
||||||
|
|
||||||
import javax.annotation.Resource;
|
import javax.annotation.Resource;
|
||||||
import java.time.Duration;
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +63,18 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
private static final String ACCOUNT_RELATION_LOCK_KEY_PREFIX = "easyflow:lock:sys:account:relation:";
|
private static final String ACCOUNT_RELATION_LOCK_KEY_PREFIX = "easyflow:lock:sys:account:relation:";
|
||||||
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
private static final Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
|
||||||
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
|
||||||
|
private static final String DEFAULT_RESET_PASSWORD = "123456";
|
||||||
|
private static final long MAX_IMPORT_FILE_SIZE_BYTES = 10L * 1024 * 1024;
|
||||||
|
private static final int MAX_IMPORT_ROWS = 5000;
|
||||||
|
private static final String IMPORT_HEAD_DEPT_CODE = "部门编码";
|
||||||
|
private static final String IMPORT_HEAD_LOGIN_NAME = "登录账号";
|
||||||
|
private static final String IMPORT_HEAD_NICKNAME = "昵称";
|
||||||
|
private static final String IMPORT_HEAD_MOBILE = "手机号";
|
||||||
|
private static final String IMPORT_HEAD_EMAIL = "邮箱";
|
||||||
|
private static final String IMPORT_HEAD_STATUS = "状态";
|
||||||
|
private static final String IMPORT_HEAD_ROLE_KEYS = "角色编码";
|
||||||
|
private static final String IMPORT_HEAD_POSITION_CODES = "岗位编码";
|
||||||
|
private static final String IMPORT_HEAD_REMARK = "备注";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private SysAccountRoleMapper sysAccountRoleMapper;
|
private SysAccountRoleMapper sysAccountRoleMapper;
|
||||||
@@ -42,7 +83,13 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
@Resource
|
@Resource
|
||||||
private SysRoleMapper sysRoleMapper;
|
private SysRoleMapper sysRoleMapper;
|
||||||
@Resource
|
@Resource
|
||||||
|
private SysPositionMapper sysPositionMapper;
|
||||||
|
@Resource
|
||||||
|
private SysDeptMapper sysDeptMapper;
|
||||||
|
@Resource
|
||||||
private RedisLockExecutor redisLockExecutor;
|
private RedisLockExecutor redisLockExecutor;
|
||||||
|
@Resource
|
||||||
|
private PlatformTransactionManager transactionManager;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@Transactional(rollbackFor = Exception.class)
|
@Transactional(rollbackFor = Exception.class)
|
||||||
@@ -55,7 +102,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
LOCK_WAIT_TIMEOUT,
|
LOCK_WAIT_TIMEOUT,
|
||||||
LOCK_LEASE_TIMEOUT,
|
LOCK_LEASE_TIMEOUT,
|
||||||
() -> {
|
() -> {
|
||||||
//sync roleIds
|
|
||||||
List<BigInteger> roleIds = entity.getRoleIds();
|
List<BigInteger> roleIds = entity.getRoleIds();
|
||||||
if (roleIds != null) {
|
if (roleIds != null) {
|
||||||
QueryWrapper delW = QueryWrapper.create();
|
QueryWrapper delW = QueryWrapper.create();
|
||||||
@@ -79,7 +125,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//sync positionIds
|
|
||||||
List<BigInteger> positionIds = entity.getPositionIds();
|
List<BigInteger> positionIds = entity.getPositionIds();
|
||||||
if (positionIds != null) {
|
if (positionIds != null) {
|
||||||
QueryWrapper delW = QueryWrapper.create();
|
QueryWrapper delW = QueryWrapper.create();
|
||||||
@@ -112,4 +157,483 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
|
|||||||
w.eq(SysAccount::getLoginName, userKey);
|
w.eq(SysAccount::getLoginName, userKey);
|
||||||
return getOne(w);
|
return getOne(w);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void resetPassword(BigInteger accountId, BigInteger operatorId) {
|
||||||
|
SysAccount record = getById(accountId);
|
||||||
|
if (record == null) {
|
||||||
|
throw new BusinessException("用户不存在");
|
||||||
|
}
|
||||||
|
Integer accountType = record.getAccountType();
|
||||||
|
if (EnumAccountType.SUPER_ADMIN.getCode().equals(accountType)) {
|
||||||
|
throw new BusinessException("不能重置超级管理员密码");
|
||||||
|
}
|
||||||
|
if (EnumAccountType.TENANT_ADMIN.getCode().equals(accountType)) {
|
||||||
|
throw new BusinessException("不能重置租户管理员密码");
|
||||||
|
}
|
||||||
|
SysAccount update = new SysAccount();
|
||||||
|
update.setId(accountId);
|
||||||
|
update.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
||||||
|
update.setPasswordResetRequired(true);
|
||||||
|
update.setModified(new Date());
|
||||||
|
update.setModifiedBy(operatorId);
|
||||||
|
updateById(update);
|
||||||
|
StpUtil.kickout(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SysAccountImportResultVo importAccounts(MultipartFile file, LoginAccount loginAccount) {
|
||||||
|
validateImportFile(file);
|
||||||
|
List<SysAccountImportRow> rows = parseImportRows(file);
|
||||||
|
SysAccountImportResultVo result = new SysAccountImportResultVo();
|
||||||
|
result.setTotalCount(rows.size());
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, SysDept> deptMap = buildDeptCodeMap();
|
||||||
|
Map<String, SysRole> roleMap = buildRoleKeyMap();
|
||||||
|
Map<String, SysPosition> positionMap = buildPositionCodeMap();
|
||||||
|
for (SysAccountImportRow row : rows) {
|
||||||
|
try {
|
||||||
|
executeInRowTransaction(() -> importSingleRow(row, loginAccount, deptMap, roleMap, positionMap));
|
||||||
|
result.setSuccessCount(result.getSuccessCount() + 1);
|
||||||
|
} catch (Exception e) {
|
||||||
|
result.setErrorCount(result.getErrorCount() + 1);
|
||||||
|
appendImportError(result, row, extractImportErrorMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeImportTemplate(OutputStream outputStream) {
|
||||||
|
EasyExcel.write(outputStream)
|
||||||
|
.head(buildImportHeadList())
|
||||||
|
.sheet("模板")
|
||||||
|
.doWrite(new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeInRowTransaction(Runnable action) {
|
||||||
|
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||||
|
template.executeWithoutResult(status -> {
|
||||||
|
try {
|
||||||
|
action.run();
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
status.setRollbackOnly();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void importSingleRow(SysAccountImportRow row,
|
||||||
|
LoginAccount loginAccount,
|
||||||
|
Map<String, SysDept> deptMap,
|
||||||
|
Map<String, SysRole> roleMap,
|
||||||
|
Map<String, SysPosition> positionMap) {
|
||||||
|
String deptCode = trimToNull(row.getDeptCode());
|
||||||
|
String loginName = trimToNull(row.getLoginName());
|
||||||
|
String nickname = trimToNull(row.getNickname());
|
||||||
|
if (deptCode == null) {
|
||||||
|
throw new BusinessException("部门编码不能为空");
|
||||||
|
}
|
||||||
|
if (loginName == null) {
|
||||||
|
throw new BusinessException("登录账号不能为空");
|
||||||
|
}
|
||||||
|
if (nickname == null) {
|
||||||
|
throw new BusinessException("昵称不能为空");
|
||||||
|
}
|
||||||
|
SysDept dept = deptMap.get(deptCode);
|
||||||
|
if (dept == null) {
|
||||||
|
throw new BusinessException("部门编码不存在: " + deptCode);
|
||||||
|
}
|
||||||
|
ensureLoginNameNotExists(loginName);
|
||||||
|
|
||||||
|
List<BigInteger> roleIds = resolveRoleIds(row.getRoleKeys(), roleMap);
|
||||||
|
List<BigInteger> positionIds = resolvePositionIds(row.getPositionCodes(), positionMap);
|
||||||
|
|
||||||
|
SysAccount entity = new SysAccount();
|
||||||
|
entity.setDeptId(dept.getId());
|
||||||
|
entity.setTenantId(loginAccount.getTenantId());
|
||||||
|
entity.setLoginName(loginName);
|
||||||
|
entity.setPassword(BCrypt.hashpw(DEFAULT_RESET_PASSWORD));
|
||||||
|
entity.setPasswordResetRequired(true);
|
||||||
|
entity.setAccountType(EnumAccountType.NORMAL.getCode());
|
||||||
|
entity.setNickname(nickname);
|
||||||
|
entity.setMobile(trimToNull(row.getMobile()));
|
||||||
|
entity.setEmail(trimToNull(row.getEmail()));
|
||||||
|
entity.setStatus(parseStatus(row.getStatus()));
|
||||||
|
entity.setRemark(trimToNull(row.getRemark()));
|
||||||
|
entity.setCreated(new Date());
|
||||||
|
entity.setCreatedBy(loginAccount.getId());
|
||||||
|
entity.setModified(new Date());
|
||||||
|
entity.setModifiedBy(loginAccount.getId());
|
||||||
|
entity.setRoleIds(roleIds);
|
||||||
|
entity.setPositionIds(positionIds);
|
||||||
|
save(entity);
|
||||||
|
syncRelations(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureLoginNameNotExists(String loginName) {
|
||||||
|
QueryWrapper wrapper = QueryWrapper.create();
|
||||||
|
wrapper.eq(SysAccount::getLoginName, loginName);
|
||||||
|
if (count(wrapper) > 0) {
|
||||||
|
throw new BusinessException("登录账号已存在: " + loginName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Integer parseStatus(String rawStatus) {
|
||||||
|
String status = trimToNull(rawStatus);
|
||||||
|
if (status == null) {
|
||||||
|
return EnumDataStatus.AVAILABLE.getCode();
|
||||||
|
}
|
||||||
|
if ("1".equals(status) || "已启用".equals(status) || "启用".equals(status)) {
|
||||||
|
return EnumDataStatus.AVAILABLE.getCode();
|
||||||
|
}
|
||||||
|
if ("0".equals(status) || "未启用".equals(status) || "停用".equals(status) || "禁用".equals(status)) {
|
||||||
|
return EnumDataStatus.UNAVAILABLE.getCode();
|
||||||
|
}
|
||||||
|
throw new BusinessException("状态不合法,仅支持 1/0 或 已启用/未启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<BigInteger> resolveRoleIds(String roleKeysText, Map<String, SysRole> roleMap) {
|
||||||
|
List<String> roleKeys = splitCodes(roleKeysText);
|
||||||
|
if (roleKeys.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<BigInteger> roleIds = new ArrayList<>(roleKeys.size());
|
||||||
|
for (String roleKey : roleKeys) {
|
||||||
|
SysRole role = roleMap.get(roleKey);
|
||||||
|
if (role == null) {
|
||||||
|
throw new BusinessException("角色编码不存在: " + roleKey);
|
||||||
|
}
|
||||||
|
roleIds.add(role.getId());
|
||||||
|
}
|
||||||
|
return roleIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<BigInteger> resolvePositionIds(String positionCodesText, Map<String, SysPosition> positionMap) {
|
||||||
|
List<String> positionCodes = splitCodes(positionCodesText);
|
||||||
|
if (positionCodes.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<BigInteger> positionIds = new ArrayList<>(positionCodes.size());
|
||||||
|
for (String positionCode : positionCodes) {
|
||||||
|
SysPosition position = positionMap.get(positionCode);
|
||||||
|
if (position == null) {
|
||||||
|
throw new BusinessException("岗位编码不存在: " + positionCode);
|
||||||
|
}
|
||||||
|
positionIds.add(position.getId());
|
||||||
|
}
|
||||||
|
return positionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> splitCodes(String rawCodes) {
|
||||||
|
String codes = trimToNull(rawCodes);
|
||||||
|
if (codes == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
String[] values = codes.split("[,,]");
|
||||||
|
List<String> result = new ArrayList<>();
|
||||||
|
Set<String> uniqueValues = new LinkedHashSet<>();
|
||||||
|
for (String value : values) {
|
||||||
|
String trimmed = trimToNull(value);
|
||||||
|
if (trimmed != null && uniqueValues.add(trimmed)) {
|
||||||
|
result.add(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, SysDept> buildDeptCodeMap() {
|
||||||
|
List<SysDept> deptList = sysDeptMapper.selectListByQuery(QueryWrapper.create());
|
||||||
|
Map<String, SysDept> deptMap = new HashMap<>();
|
||||||
|
for (SysDept dept : deptList) {
|
||||||
|
String deptCode = trimToNull(dept.getDeptCode());
|
||||||
|
if (deptCode != null) {
|
||||||
|
deptMap.putIfAbsent(deptCode, dept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deptMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, SysRole> buildRoleKeyMap() {
|
||||||
|
List<SysRole> roleList = sysRoleMapper.selectListByQuery(QueryWrapper.create());
|
||||||
|
Map<String, SysRole> roleMap = new HashMap<>();
|
||||||
|
for (SysRole role : roleList) {
|
||||||
|
String roleKey = trimToNull(role.getRoleKey());
|
||||||
|
if (roleKey != null) {
|
||||||
|
roleMap.putIfAbsent(roleKey, role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roleMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, SysPosition> buildPositionCodeMap() {
|
||||||
|
List<SysPosition> positionList = sysPositionMapper.selectListByQuery(QueryWrapper.create());
|
||||||
|
Map<String, SysPosition> positionMap = new HashMap<>();
|
||||||
|
for (SysPosition position : positionList) {
|
||||||
|
String positionCode = trimToNull(position.getPositionCode());
|
||||||
|
if (positionCode != null) {
|
||||||
|
positionMap.putIfAbsent(positionCode, position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return positionMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SysAccountImportRow> parseImportRows(MultipartFile file) {
|
||||||
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
|
SysAccountExcelReadListener listener = new SysAccountExcelReadListener();
|
||||||
|
FastExcel.read(inputStream, listener)
|
||||||
|
.sheet()
|
||||||
|
.doRead();
|
||||||
|
return listener.getRows();
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new BusinessException("用户导入文件解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateImportFile(MultipartFile file) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new BusinessException("导入文件不能为空");
|
||||||
|
}
|
||||||
|
if (file.getSize() > MAX_IMPORT_FILE_SIZE_BYTES) {
|
||||||
|
throw new BusinessException("导入文件大小不能超过10MB");
|
||||||
|
}
|
||||||
|
String fileName = file.getOriginalFilename();
|
||||||
|
String lowerName = fileName == null ? "" : fileName.toLowerCase();
|
||||||
|
if (!(lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls"))) {
|
||||||
|
throw new BusinessException("仅支持 xlsx/xls 文件");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendImportError(SysAccountImportResultVo result, SysAccountImportRow row, String reason) {
|
||||||
|
SysAccountImportErrorRowVo errorRow = new SysAccountImportErrorRowVo();
|
||||||
|
errorRow.setRowNumber(row.getRowNumber());
|
||||||
|
errorRow.setDeptCode(row.getDeptCode());
|
||||||
|
errorRow.setLoginName(row.getLoginName());
|
||||||
|
errorRow.setReason(reason);
|
||||||
|
result.getErrorRows().add(errorRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractImportErrorMessage(Exception e) {
|
||||||
|
if (e == null) {
|
||||||
|
return "导入失败";
|
||||||
|
}
|
||||||
|
if (e.getCause() != null && StringUtil.hasText(e.getCause().getMessage())) {
|
||||||
|
return e.getCause().getMessage();
|
||||||
|
}
|
||||||
|
if (StringUtil.hasText(e.getMessage())) {
|
||||||
|
return e.getMessage();
|
||||||
|
}
|
||||||
|
return "导入失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<List<String>> buildImportHeadList() {
|
||||||
|
List<List<String>> headList = new ArrayList<>(9);
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_DEPT_CODE));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_LOGIN_NAME));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_NICKNAME));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_MOBILE));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_EMAIL));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_STATUS));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_ROLE_KEYS));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_POSITION_CODES));
|
||||||
|
headList.add(Collections.singletonList(IMPORT_HEAD_REMARK));
|
||||||
|
return headList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String trimToNull(String value) {
|
||||||
|
if (!StringUtil.hasText(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class SysAccountImportRow {
|
||||||
|
private Integer rowNumber;
|
||||||
|
private String deptCode;
|
||||||
|
private String loginName;
|
||||||
|
private String nickname;
|
||||||
|
private String mobile;
|
||||||
|
private String email;
|
||||||
|
private String status;
|
||||||
|
private String roleKeys;
|
||||||
|
private String positionCodes;
|
||||||
|
private String remark;
|
||||||
|
|
||||||
|
public Integer getRowNumber() {
|
||||||
|
return rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRowNumber(Integer rowNumber) {
|
||||||
|
this.rowNumber = rowNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeptCode() {
|
||||||
|
return deptCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDeptCode(String deptCode) {
|
||||||
|
this.deptCode = deptCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getLoginName() {
|
||||||
|
return loginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLoginName(String loginName) {
|
||||||
|
this.loginName = loginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNickname(String nickname) {
|
||||||
|
this.nickname = nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMobile() {
|
||||||
|
return mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMobile(String mobile) {
|
||||||
|
this.mobile = mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEmail(String email) {
|
||||||
|
this.email = email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRoleKeys() {
|
||||||
|
return roleKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRoleKeys(String roleKeys) {
|
||||||
|
this.roleKeys = roleKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPositionCodes() {
|
||||||
|
return positionCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPositionCodes(String positionCodes) {
|
||||||
|
this.positionCodes = positionCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRemark() {
|
||||||
|
return remark;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRemark(String remark) {
|
||||||
|
this.remark = remark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SysAccountExcelReadListener implements ReadListener<LinkedHashMap<Integer, Object>> {
|
||||||
|
|
||||||
|
private final Map<String, Integer> headIndex = new HashMap<>();
|
||||||
|
private final List<SysAccountImportRow> rows = new ArrayList<>();
|
||||||
|
private int sheetRowNo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void invoke(LinkedHashMap<Integer, Object> data, AnalysisContext context) {
|
||||||
|
sheetRowNo++;
|
||||||
|
String deptCode = getCellValue(data, IMPORT_HEAD_DEPT_CODE);
|
||||||
|
String loginName = getCellValue(data, IMPORT_HEAD_LOGIN_NAME);
|
||||||
|
String nickname = getCellValue(data, IMPORT_HEAD_NICKNAME);
|
||||||
|
String mobile = getCellValue(data, IMPORT_HEAD_MOBILE);
|
||||||
|
String email = getCellValue(data, IMPORT_HEAD_EMAIL);
|
||||||
|
String status = getCellValue(data, IMPORT_HEAD_STATUS);
|
||||||
|
String roleKeys = getCellValue(data, IMPORT_HEAD_ROLE_KEYS);
|
||||||
|
String positionCodes = getCellValue(data, IMPORT_HEAD_POSITION_CODES);
|
||||||
|
String remark = getCellValue(data, IMPORT_HEAD_REMARK);
|
||||||
|
if (!StringUtil.hasText(deptCode)
|
||||||
|
&& !StringUtil.hasText(loginName)
|
||||||
|
&& !StringUtil.hasText(nickname)
|
||||||
|
&& !StringUtil.hasText(mobile)
|
||||||
|
&& !StringUtil.hasText(email)
|
||||||
|
&& !StringUtil.hasText(status)
|
||||||
|
&& !StringUtil.hasText(roleKeys)
|
||||||
|
&& !StringUtil.hasText(positionCodes)
|
||||||
|
&& !StringUtil.hasText(remark)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (rows.size() >= MAX_IMPORT_ROWS) {
|
||||||
|
throw new BusinessException("单次最多导入5000个用户");
|
||||||
|
}
|
||||||
|
SysAccountImportRow row = new SysAccountImportRow();
|
||||||
|
row.setRowNumber(sheetRowNo + 1);
|
||||||
|
row.setDeptCode(deptCode);
|
||||||
|
row.setLoginName(loginName);
|
||||||
|
row.setNickname(nickname);
|
||||||
|
row.setMobile(mobile);
|
||||||
|
row.setEmail(email);
|
||||||
|
row.setStatus(status);
|
||||||
|
row.setRoleKeys(roleKeys);
|
||||||
|
row.setPositionCodes(positionCodes);
|
||||||
|
row.setRemark(remark);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) {
|
||||||
|
for (Map.Entry<Integer, ReadCellData<?>> entry : headMap.entrySet()) {
|
||||||
|
String headValue = entry.getValue() == null ? null : entry.getValue().getStringValue();
|
||||||
|
String header = trimToNull(headValue);
|
||||||
|
if (header != null) {
|
||||||
|
headIndex.put(header, entry.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<String> requiredHeads = List.of(
|
||||||
|
IMPORT_HEAD_DEPT_CODE,
|
||||||
|
IMPORT_HEAD_LOGIN_NAME,
|
||||||
|
IMPORT_HEAD_NICKNAME,
|
||||||
|
IMPORT_HEAD_MOBILE,
|
||||||
|
IMPORT_HEAD_EMAIL,
|
||||||
|
IMPORT_HEAD_STATUS,
|
||||||
|
IMPORT_HEAD_ROLE_KEYS,
|
||||||
|
IMPORT_HEAD_POSITION_CODES,
|
||||||
|
IMPORT_HEAD_REMARK
|
||||||
|
);
|
||||||
|
for (String requiredHead : requiredHeads) {
|
||||||
|
if (!headIndex.containsKey(requiredHead)) {
|
||||||
|
throw new BusinessException("导入模板表头不正确,必须包含:" + String.join("、", requiredHeads));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void doAfterAllAnalysed(AnalysisContext context) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SysAccountImportRow> getRows() {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getCellValue(Map<Integer, Object> row, String headName) {
|
||||||
|
Integer index = headIndex.get(headName);
|
||||||
|
if (index == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Object value = row.get(index);
|
||||||
|
return value == null ? null : String.valueOf(value).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package tech.easyflow.system.util;
|
||||||
|
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户密码策略。
|
||||||
|
*/
|
||||||
|
public final class SysPasswordPolicy {
|
||||||
|
|
||||||
|
public static final String STRONG_PASSWORD_MESSAGE = "密码必须至少8位,且包含大写字母、小写字母、数字和特殊字符";
|
||||||
|
|
||||||
|
private static final Pattern STRONG_PASSWORD_PATTERN = Pattern.compile(
|
||||||
|
"^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[^A-Za-z\\d]).{8,}$"
|
||||||
|
);
|
||||||
|
|
||||||
|
private SysPasswordPolicy() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isStrongPassword(String password) {
|
||||||
|
return password != null && STRONG_PASSWORD_PATTERN.matcher(password).matches();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void validateStrongPassword(String password) {
|
||||||
|
if (!isStrongPassword(password)) {
|
||||||
|
throw new BusinessException(STRONG_PASSWORD_MESSAGE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package tech.easyflow.system.util;
|
||||||
|
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import tech.easyflow.common.web.exceptions.BusinessException;
|
||||||
|
|
||||||
|
public class SysPasswordPolicyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void shouldAcceptStrongPassword() {
|
||||||
|
Assert.assertTrue(SysPasswordPolicy.isStrongPassword("Abcd1234!"));
|
||||||
|
SysPasswordPolicy.validateStrongPassword("Abcd1234!");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = BusinessException.class)
|
||||||
|
public void shouldRejectWeakPassword() {
|
||||||
|
Assert.assertFalse(SysPasswordPolicy.isStrongPassword("123456"));
|
||||||
|
SysPasswordPolicy.validateStrongPassword("123456");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export namespace AuthApi {
|
|||||||
/** 登录接口返回值 */
|
/** 登录接口返回值 */
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
forceChangePassword?: boolean;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,5 +23,23 @@
|
|||||||
"newPwd": "NewPassword",
|
"newPwd": "NewPassword",
|
||||||
"confirmPwd": "ConfirmPassword",
|
"confirmPwd": "ConfirmPassword",
|
||||||
"repeatPwd": "Please confirm your password again",
|
"repeatPwd": "Please confirm your password again",
|
||||||
"notSamePwd": "The two passwords are inconsistent"
|
"notSamePwd": "The two passwords are inconsistent",
|
||||||
|
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
||||||
|
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages.",
|
||||||
|
"resetPassword": "Reset Password",
|
||||||
|
"resetPasswordConfirm": "Reset this account password to 123456? The user will be required to change it on next login.",
|
||||||
|
"resetPasswordSuccess": "Password has been reset to 123456 and must be changed on next login",
|
||||||
|
"importTitle": "Import Users",
|
||||||
|
"importUploadTitle": "Drag the Excel file here, or click to select a file",
|
||||||
|
"importUploadDesc": "Only .xlsx / .xls files are supported. Import only creates users and duplicate accounts will fail.",
|
||||||
|
"importSelectFileRequired": "Please select a file to import",
|
||||||
|
"downloadTemplate": "Download Template",
|
||||||
|
"importFinished": "User import completed",
|
||||||
|
"importResultTitle": "Import Result",
|
||||||
|
"importTotalCount": "Total",
|
||||||
|
"importSuccessCount": "Success",
|
||||||
|
"importErrorCount": "Failed",
|
||||||
|
"importRowNumber": "Row",
|
||||||
|
"importDeptCode": "Dept Code",
|
||||||
|
"importReason": "Reason"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,5 +24,23 @@
|
|||||||
"newPwd": "新密码",
|
"newPwd": "新密码",
|
||||||
"confirmPwd": "确认密码",
|
"confirmPwd": "确认密码",
|
||||||
"repeatPwd": "请再次输入密码",
|
"repeatPwd": "请再次输入密码",
|
||||||
"notSamePwd": "两次输入的密码不一致"
|
"notSamePwd": "两次输入的密码不一致",
|
||||||
|
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||||
|
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面",
|
||||||
|
"resetPassword": "重置密码",
|
||||||
|
"resetPasswordConfirm": "确认将该用户密码重置为 123456 吗?重置后用户下次登录必须先修改密码。",
|
||||||
|
"resetPasswordSuccess": "密码已重置为 123456,用户下次登录需修改密码",
|
||||||
|
"importTitle": "导入用户",
|
||||||
|
"importUploadTitle": "拖拽 Excel 文件到此处,或点击选择文件",
|
||||||
|
"importUploadDesc": "仅支持 .xlsx / .xls,导入只新增用户,重复账号会报错",
|
||||||
|
"importSelectFileRequired": "请先选择要导入的文件",
|
||||||
|
"downloadTemplate": "下载导入模板",
|
||||||
|
"importFinished": "用户导入完成",
|
||||||
|
"importResultTitle": "导入结果",
|
||||||
|
"importTotalCount": "总条数",
|
||||||
|
"importSuccessCount": "成功数",
|
||||||
|
"importErrorCount": "失败数",
|
||||||
|
"importRowNumber": "行号",
|
||||||
|
"importDeptCode": "部门编码",
|
||||||
|
"importReason": "失败原因"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import {
|
|||||||
removeDevLoginQuery,
|
removeDevLoginQuery,
|
||||||
shouldAttemptDevLogin,
|
shouldAttemptDevLogin,
|
||||||
} from './dev-login';
|
} from './dev-login';
|
||||||
|
import {
|
||||||
|
buildForcePasswordRoute,
|
||||||
|
isForcePasswordRoute,
|
||||||
|
notifyForcePasswordChange,
|
||||||
|
shouldForcePasswordChange,
|
||||||
|
} from '#/utils/password-reset';
|
||||||
|
|
||||||
interface NetworkConnectionLike {
|
interface NetworkConnectionLike {
|
||||||
effectiveType?: string;
|
effectiveType?: string;
|
||||||
@@ -183,6 +189,10 @@ function setupAccessGuard(router: Router) {
|
|||||||
// 基本路由,这些路由不需要进入权限拦截
|
// 基本路由,这些路由不需要进入权限拦截
|
||||||
if (coreRouteNames.includes(to.name as string)) {
|
if (coreRouteNames.includes(to.name as string)) {
|
||||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||||
|
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||||
|
if (shouldForcePasswordChange(currentUser)) {
|
||||||
|
return buildForcePasswordRoute();
|
||||||
|
}
|
||||||
return decodeURIComponent(
|
return decodeURIComponent(
|
||||||
(to.query?.redirect as string) ||
|
(to.query?.redirect as string) ||
|
||||||
userStore.userInfo?.homePath ||
|
userStore.userInfo?.homePath ||
|
||||||
@@ -219,6 +229,14 @@ function setupAccessGuard(router: Router) {
|
|||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||||
|
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
|
||||||
|
if (from.name) {
|
||||||
|
notifyForcePasswordChange();
|
||||||
|
}
|
||||||
|
return buildForcePasswordRoute();
|
||||||
|
}
|
||||||
|
|
||||||
// 是否已经生成过动态路由
|
// 是否已经生成过动态路由
|
||||||
if (accessStore.isAccessChecked) {
|
if (accessStore.isAccessChecked) {
|
||||||
return true;
|
return true;
|
||||||
@@ -226,7 +244,6 @@ function setupAccessGuard(router: Router) {
|
|||||||
|
|
||||||
// 生成路由表
|
// 生成路由表
|
||||||
// 当前登录用户拥有的角色标识列表
|
// 当前登录用户拥有的角色标识列表
|
||||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
|
||||||
const userRoles = userInfo.roles ?? [];
|
const userRoles = userInfo.roles ?? [];
|
||||||
|
|
||||||
// 生成菜单和路由
|
// 生成菜单和路由
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
logoutApi,
|
logoutApi,
|
||||||
} from '#/api';
|
} from '#/api';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import {
|
||||||
|
buildForcePasswordRoute,
|
||||||
|
shouldForcePasswordChange,
|
||||||
|
} from '#/utils/password-reset';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
@@ -28,6 +32,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
async function finalizeLogin(
|
async function finalizeLogin(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
forceChangePassword?: boolean,
|
||||||
options: {
|
options: {
|
||||||
notify?: boolean;
|
notify?: boolean;
|
||||||
onSuccess?: () => Promise<void> | void;
|
onSuccess?: () => Promise<void> | void;
|
||||||
@@ -47,8 +52,17 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
userStore.setUserInfo(userInfo);
|
userStore.setUserInfo(userInfo);
|
||||||
accessStore.setAccessCodes(accessCodes);
|
accessStore.setAccessCodes(accessCodes);
|
||||||
|
|
||||||
|
const forcePasswordChange = shouldForcePasswordChange(
|
||||||
|
userInfo,
|
||||||
|
forceChangePassword,
|
||||||
|
);
|
||||||
|
|
||||||
if (accessStore.loginExpired) {
|
if (accessStore.loginExpired) {
|
||||||
accessStore.setLoginExpired(false);
|
accessStore.setLoginExpired(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forcePasswordChange) {
|
||||||
|
await router.push(buildForcePasswordRoute());
|
||||||
} else if (!options.skipRedirect) {
|
} else if (!options.skipRedirect) {
|
||||||
const homePath =
|
const homePath =
|
||||||
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
||||||
@@ -81,10 +95,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
loginLoading.value = true;
|
loginLoading.value = true;
|
||||||
const { token: accessToken } = await loginApi(params);
|
const { forceChangePassword, token: accessToken } = await loginApi(params);
|
||||||
|
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
return await finalizeLogin(accessToken, { onSuccess });
|
return await finalizeLogin(accessToken, forceChangePassword, {
|
||||||
|
onSuccess,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loginLoading.value = false;
|
loginLoading.value = false;
|
||||||
@@ -96,13 +112,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function authDevLogin(account: string) {
|
async function authDevLogin(account: string) {
|
||||||
const { token: accessToken } = await devLoginApi({ account });
|
const { forceChangePassword, token: accessToken } = await devLoginApi({
|
||||||
|
account,
|
||||||
|
});
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return {
|
return {
|
||||||
userInfo: null,
|
userInfo: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return finalizeLogin(accessToken, {
|
return finalizeLogin(accessToken, forceChangePassword, {
|
||||||
notify: false,
|
notify: false,
|
||||||
skipRedirect: true,
|
skipRedirect: true,
|
||||||
});
|
});
|
||||||
|
|||||||
6
easyflow-ui-admin/app/src/utils/password-policy.ts
Normal file
6
easyflow-ui-admin/app/src/utils/password-policy.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const strongPasswordPattern =
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/;
|
||||||
|
|
||||||
|
export function isStrongPassword(value?: string) {
|
||||||
|
return !!value && strongPasswordPattern.test(value);
|
||||||
|
}
|
||||||
52
easyflow-ui-admin/app/src/utils/password-reset.ts
Normal file
52
easyflow-ui-admin/app/src/utils/password-reset.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
|
||||||
|
|
||||||
|
let lastForcePasswordNoticeAt = 0;
|
||||||
|
|
||||||
|
export function buildForcePasswordRoute() {
|
||||||
|
return {
|
||||||
|
name: 'Profile',
|
||||||
|
query: {
|
||||||
|
force: '1',
|
||||||
|
tab: 'password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isForcePasswordRoute(
|
||||||
|
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>,
|
||||||
|
) {
|
||||||
|
return route.name === 'Profile' && route.query?.tab === 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldForcePasswordChange(
|
||||||
|
userInfo?: null | Record<string, any>,
|
||||||
|
forceChangePassword?: boolean,
|
||||||
|
) {
|
||||||
|
return !!forceChangePassword || !!userInfo?.passwordResetRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveForcePasswordPath(router: Router) {
|
||||||
|
return router.resolve(buildForcePasswordRoute()).fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyForcePasswordChange() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastForcePasswordNoticeAt < FORCE_PASSWORD_NOTICE_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastForcePasswordNoticeAt = now;
|
||||||
|
ElMessage({
|
||||||
|
message: $t('sysAccount.forceChangePasswordNavigateTip'),
|
||||||
|
type: 'warning',
|
||||||
|
duration: 2200,
|
||||||
|
grouping: true,
|
||||||
|
plain: true,
|
||||||
|
showClose: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import { Profile } from '@easyflow/common-ui';
|
import { Profile } from '@easyflow/common-ui';
|
||||||
@@ -17,7 +17,20 @@ const userStore = useUserStore();
|
|||||||
|
|
||||||
const tabsValue = ref<string>('basic');
|
const tabsValue = ref<string>('basic');
|
||||||
|
|
||||||
const tabs = [
|
const forcePasswordChange = computed(() => {
|
||||||
|
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
if (forcePasswordChange.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: $t('settingsConfig.updatePwd'),
|
||||||
|
value: 'password',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
label: $t('settingsConfig.basic'),
|
label: $t('settingsConfig.basic'),
|
||||||
value: 'basic',
|
value: 'basic',
|
||||||
@@ -26,13 +39,20 @@ const tabs = [
|
|||||||
label: $t('settingsConfig.updatePwd'),
|
label: $t('settingsConfig.updatePwd'),
|
||||||
value: 'password',
|
value: 'password',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (route.query.tab) {
|
|
||||||
tabsValue.value = route.query.tab as string;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.force, route.query.tab, userStore.userInfo?.passwordResetRequired],
|
||||||
|
() => {
|
||||||
|
if (forcePasswordChange.value) {
|
||||||
|
tabsValue.value = 'password';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabsValue.value = (route.query.tab as string) || 'basic';
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Profile
|
<Profile
|
||||||
@@ -43,8 +63,8 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
|
||||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||||
|
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||||
</template>
|
</template>
|
||||||
</Profile>
|
</Profile>
|
||||||
|
|||||||
@@ -2,15 +2,28 @@
|
|||||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
||||||
|
import { preferences } from '@easyflow/preferences';
|
||||||
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
import { api } from '#/api/request';
|
import { api } from '#/api/request';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import { useAuthStore } from '#/store';
|
||||||
|
import { isStrongPassword } from '#/utils/password-policy';
|
||||||
|
|
||||||
const profilePasswordSettingRef = ref();
|
const profilePasswordSettingRef = ref();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const isForcedPasswordChange = computed(() => {
|
||||||
|
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||||
|
});
|
||||||
|
|
||||||
const formSchema = computed((): EasyFlowFormSchema[] => {
|
const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||||
return [
|
return [
|
||||||
@@ -30,6 +43,17 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
|||||||
passwordStrength: true,
|
passwordStrength: true,
|
||||||
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
|
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
|
||||||
},
|
},
|
||||||
|
renderComponentContent() {
|
||||||
|
return {
|
||||||
|
strengthText: () => $t('sysAccount.passwordStrongTip'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rules: z
|
||||||
|
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||||
|
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||||
|
.refine((value) => isStrongPassword(value), {
|
||||||
|
message: $t('sysAccount.passwordStrongTip'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'confirmPassword',
|
fieldName: 'confirmPassword',
|
||||||
@@ -56,14 +80,22 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateLoading = ref(false);
|
const updateLoading = ref(false);
|
||||||
function handleSubmit(values: any) {
|
async function handleSubmit(values: any) {
|
||||||
updateLoading.value = true;
|
updateLoading.value = true;
|
||||||
api.post('/api/v1/sysAccount/updatePassword', values).then((res) => {
|
try {
|
||||||
updateLoading.value = false;
|
const res = await api.post('/api/v1/sysAccount/updatePassword', values);
|
||||||
if (res.errorCode === 0) {
|
if (res.errorCode === 0) {
|
||||||
ElMessage.success($t('message.success'));
|
ElMessage.success($t('message.success'));
|
||||||
|
const userInfo = await authStore.fetchUserInfo();
|
||||||
|
if (isForcedPasswordChange.value) {
|
||||||
|
await router.replace(
|
||||||
|
userInfo?.homePath || preferences.app.defaultHomePath || '/',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
updateLoading.value = false;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { UploadFile } from 'element-plus';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { EasyFlowPanelModal } from '@easyflow/common-ui';
|
||||||
|
import { downloadFileFromBlob } from '@easyflow/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CircleCloseFilled,
|
||||||
|
Document,
|
||||||
|
Download,
|
||||||
|
SuccessFilled,
|
||||||
|
UploadFilled,
|
||||||
|
WarningFilled,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElIcon,
|
||||||
|
ElMessage,
|
||||||
|
ElTable,
|
||||||
|
ElTableColumn,
|
||||||
|
ElUpload,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const emit = defineEmits(['reload']);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
openDialog,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const fileList = ref<any[]>([]);
|
||||||
|
const currentFile = ref<File | null>(null);
|
||||||
|
const submitLoading = ref(false);
|
||||||
|
const downloadLoading = ref(false);
|
||||||
|
const importResult = ref<any>(null);
|
||||||
|
|
||||||
|
const hasErrors = computed(() => (importResult.value?.errorCount || 0) > 0);
|
||||||
|
const selectedFileName = computed(() => currentFile.value?.name || '');
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
dialogVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
if (submitLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dialogVisible.value = false;
|
||||||
|
resetDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDialog() {
|
||||||
|
fileList.value = [];
|
||||||
|
currentFile.value = null;
|
||||||
|
submitLoading.value = false;
|
||||||
|
downloadLoading.value = false;
|
||||||
|
importResult.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(uploadFile: UploadFile) {
|
||||||
|
currentFile.value = uploadFile.raw || null;
|
||||||
|
fileList.value = uploadFile.raw ? [uploadFile] : [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelectedFile() {
|
||||||
|
currentFile.value = null;
|
||||||
|
fileList.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadTemplate() {
|
||||||
|
downloadLoading.value = true;
|
||||||
|
try {
|
||||||
|
const blob = await api.download('/api/v1/sysAccount/downloadImportTemplate');
|
||||||
|
downloadFileFromBlob({
|
||||||
|
fileName: 'user_import_template.xlsx',
|
||||||
|
source: blob,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
downloadLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (!currentFile.value) {
|
||||||
|
ElMessage.warning($t('sysAccount.importSelectFileRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', currentFile.value);
|
||||||
|
submitLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.postFile('/api/v1/sysAccount/importExcel', formData, {
|
||||||
|
timeout: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
importResult.value = res.data;
|
||||||
|
ElMessage.success($t('sysAccount.importFinished'));
|
||||||
|
emit('reload');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EasyFlowPanelModal
|
||||||
|
v-model:open="dialogVisible"
|
||||||
|
width="min(960px, 92vw)"
|
||||||
|
:title="$t('sysAccount.importTitle')"
|
||||||
|
:before-close="closeDialog"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
:show-confirm-button="false"
|
||||||
|
>
|
||||||
|
<div class="sys-account-import-dialog">
|
||||||
|
<ElUpload
|
||||||
|
:file-list="fileList"
|
||||||
|
drag
|
||||||
|
action="#"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
:auto-upload="false"
|
||||||
|
:on-change="onFileChange"
|
||||||
|
:limit="1"
|
||||||
|
:show-file-list="false"
|
||||||
|
class="sys-account-upload-area"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center gap-2 px-8 py-10 text-center"
|
||||||
|
>
|
||||||
|
<ElIcon class="text-4xl text-[var(--el-text-color-secondary)]">
|
||||||
|
<UploadFilled />
|
||||||
|
</ElIcon>
|
||||||
|
<div class="text-[15px] font-semibold text-[var(--el-text-color-primary)]">
|
||||||
|
{{ $t('sysAccount.importUploadTitle') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-[13px] text-[var(--el-text-color-secondary)]">
|
||||||
|
{{ $t('sysAccount.importUploadDesc') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElUpload>
|
||||||
|
|
||||||
|
<div v-if="selectedFileName" class="selected-file-card">
|
||||||
|
<div class="selected-file-main">
|
||||||
|
<ElIcon class="selected-file-icon"><Document /></ElIcon>
|
||||||
|
<div class="selected-file-name" :title="selectedFileName">
|
||||||
|
{{ selectedFileName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
:icon="CircleCloseFilled"
|
||||||
|
class="remove-file-btn"
|
||||||
|
@click="clearSelectedFile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="importResult" class="result-wrap">
|
||||||
|
<div class="result-head">
|
||||||
|
<div class="result-title-wrap">
|
||||||
|
<ElIcon
|
||||||
|
v-if="hasErrors"
|
||||||
|
class="result-state-icon text-[var(--el-color-warning)]"
|
||||||
|
>
|
||||||
|
<WarningFilled />
|
||||||
|
</ElIcon>
|
||||||
|
<ElIcon
|
||||||
|
v-else
|
||||||
|
class="result-state-icon text-[var(--el-color-success)]"
|
||||||
|
>
|
||||||
|
<SuccessFilled />
|
||||||
|
</ElIcon>
|
||||||
|
<span class="result-title-text">
|
||||||
|
{{ $t('sysAccount.importResultTitle') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="result-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">{{ $t('sysAccount.importTotalCount') }}</div>
|
||||||
|
<div class="stat-value">{{ importResult.totalCount || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">{{ $t('sysAccount.importSuccessCount') }}</div>
|
||||||
|
<div class="stat-value success-text">
|
||||||
|
{{ importResult.successCount || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-label">{{ $t('sysAccount.importErrorCount') }}</div>
|
||||||
|
<div class="stat-value" :class="hasErrors ? 'danger-text' : ''">
|
||||||
|
{{ importResult.errorCount || 0 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ElTable
|
||||||
|
v-if="hasErrors"
|
||||||
|
:data="importResult.errorRows || []"
|
||||||
|
size="small"
|
||||||
|
class="result-error-table"
|
||||||
|
>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="rowNumber"
|
||||||
|
:label="$t('sysAccount.importRowNumber')"
|
||||||
|
width="96"
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="deptCode"
|
||||||
|
:label="$t('sysAccount.importDeptCode')"
|
||||||
|
min-width="140"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="loginName"
|
||||||
|
:label="$t('sysAccount.loginName')"
|
||||||
|
min-width="160"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<ElTableColumn
|
||||||
|
prop="reason"
|
||||||
|
:label="$t('sysAccount.importReason')"
|
||||||
|
min-width="260"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<ElButton @click="closeDialog">
|
||||||
|
{{ $t('button.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:icon="Download"
|
||||||
|
:disabled="downloadLoading"
|
||||||
|
@click="downloadTemplate"
|
||||||
|
>
|
||||||
|
{{ $t('sysAccount.downloadTemplate') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:loading="submitLoading"
|
||||||
|
:disabled="submitLoading"
|
||||||
|
@click="handleImport"
|
||||||
|
>
|
||||||
|
{{ $t('button.startImport') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</EasyFlowPanelModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sys-account-import-dialog {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sys-account-upload-area :deep(.el-upload-dragger) {
|
||||||
|
border-radius: 18px;
|
||||||
|
border-color: hsl(var(--border) / 0.68);
|
||||||
|
background: hsl(var(--surface) / 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border: 1px solid hsl(var(--border) / 0.72);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: hsl(var(--surface-subtle) / 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-icon {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-wrap {
|
||||||
|
border: 1px solid hsl(var(--border) / 0.72);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
background: hsl(var(--surface-subtle) / 0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-head {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: hsl(var(--surface) / 0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-text {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-text {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,13 @@ import type { FormInstance } from 'element-plus';
|
|||||||
|
|
||||||
import { markRaw, onMounted, ref } from 'vue';
|
import { markRaw, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { Delete, MoreFilled, Plus } from '@element-plus/icons-vue';
|
import {
|
||||||
|
Delete,
|
||||||
|
Lock,
|
||||||
|
MoreFilled,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
} from '@element-plus/icons-vue';
|
||||||
import {
|
import {
|
||||||
ElAvatar,
|
ElAvatar,
|
||||||
ElButton,
|
ElButton,
|
||||||
@@ -24,6 +30,7 @@ import PageData from '#/components/page/PageData.vue';
|
|||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import { useDictStore } from '#/store';
|
import { useDictStore } from '#/store';
|
||||||
|
|
||||||
|
import SysAccountImportModal from './SysAccountImportModal.vue';
|
||||||
import SysAccountModal from './SysAccountModal.vue';
|
import SysAccountModal from './SysAccountModal.vue';
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -31,6 +38,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pageDataRef = ref();
|
const pageDataRef = ref();
|
||||||
|
const importDialog = ref();
|
||||||
const saveDialog = ref();
|
const saveDialog = ref();
|
||||||
const dictStore = useDictStore();
|
const dictStore = useDictStore();
|
||||||
const headerButtons = [
|
const headerButtons = [
|
||||||
@@ -42,6 +50,13 @@ const headerButtons = [
|
|||||||
data: { action: 'create' },
|
data: { action: 'create' },
|
||||||
permission: '/api/v1/sysAccount/save',
|
permission: '/api/v1/sysAccount/save',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'import',
|
||||||
|
text: $t('button.import'),
|
||||||
|
icon: markRaw(Upload),
|
||||||
|
data: { action: 'import' },
|
||||||
|
permission: '/api/v1/sysAccount/save',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function initDict() {
|
function initDict() {
|
||||||
@@ -57,6 +72,16 @@ function reset(formEl?: FormInstance) {
|
|||||||
function showDialog(row: any) {
|
function showDialog(row: any) {
|
||||||
saveDialog.value.openDialog({ ...row });
|
saveDialog.value.openDialog({ ...row });
|
||||||
}
|
}
|
||||||
|
function openImportDialog() {
|
||||||
|
importDialog.value.openDialog();
|
||||||
|
}
|
||||||
|
function handleHeaderButtonClick(payload: any) {
|
||||||
|
if (payload?.key === 'import') {
|
||||||
|
openImportDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showDialog({});
|
||||||
|
}
|
||||||
function remove(row: any) {
|
function remove(row: any) {
|
||||||
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
|
||||||
confirmButtonText: $t('message.ok'),
|
confirmButtonText: $t('message.ok'),
|
||||||
@@ -84,6 +109,36 @@ function remove(row: any) {
|
|||||||
},
|
},
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
function resetPassword(row: any) {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
$t('sysAccount.resetPasswordConfirm'),
|
||||||
|
$t('message.noticeTitle'),
|
||||||
|
{
|
||||||
|
confirmButtonText: $t('message.ok'),
|
||||||
|
cancelButtonText: $t('message.cancel'),
|
||||||
|
type: 'warning',
|
||||||
|
beforeClose: (action, instance, done) => {
|
||||||
|
if (action === 'confirm') {
|
||||||
|
instance.confirmButtonLoading = true;
|
||||||
|
api
|
||||||
|
.post('/api/v1/sysAccount/resetPassword', { id: row.id })
|
||||||
|
.then((res) => {
|
||||||
|
instance.confirmButtonLoading = false;
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success($t('sysAccount.resetPasswordSuccess'));
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
instance.confirmButtonLoading = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
function isAdmin(data: any) {
|
function isAdmin(data: any) {
|
||||||
return data?.accountType === 1 || data?.accountType === 99;
|
return data?.accountType === 1 || data?.accountType === 99;
|
||||||
}
|
}
|
||||||
@@ -91,13 +146,14 @@ function isAdmin(data: any) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col gap-6 p-6">
|
<div class="flex h-full flex-col gap-6 p-6">
|
||||||
|
<SysAccountImportModal ref="importDialog" @reload="reset" />
|
||||||
<SysAccountModal ref="saveDialog" @reload="reset" />
|
<SysAccountModal ref="saveDialog" @reload="reset" />
|
||||||
<ListPageShell>
|
<ListPageShell>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<HeaderSearch
|
<HeaderSearch
|
||||||
:buttons="headerButtons"
|
:buttons="headerButtons"
|
||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
@button-click="showDialog({})"
|
@button-click="handleHeaderButtonClick"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<PageData
|
<PageData
|
||||||
@@ -193,6 +249,13 @@ function isAdmin(data: any) {
|
|||||||
|
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
|
<div v-access:code="'/api/v1/sysAccount/save'">
|
||||||
|
<ElDropdownItem @click="resetPassword(row)">
|
||||||
|
<ElButton type="primary" :icon="Lock" link>
|
||||||
|
{{ $t('sysAccount.resetPassword') }}
|
||||||
|
</ElButton>
|
||||||
|
</ElDropdownItem>
|
||||||
|
</div>
|
||||||
<div v-access:code="'/api/v1/sysAccount/remove'">
|
<div v-access:code="'/api/v1/sysAccount/remove'">
|
||||||
<ElDropdownItem @click="remove(row)">
|
<ElDropdownItem @click="remove(row)">
|
||||||
<ElButton type="danger" :icon="Delete" link>
|
<ElButton type="danger" :icon="Delete" link>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { FormInstance } from 'element-plus';
|
|||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
|
|
||||||
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
import { EasyFlowFormModal, EasyFlowInputPassword } from '@easyflow/common-ui';
|
||||||
|
|
||||||
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
import { ElForm, ElFormItem, ElInput, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import DictSelect from '#/components/dict/DictSelect.vue';
|
|||||||
// import Cropper from '#/components/upload/Cropper.vue';
|
// import Cropper from '#/components/upload/Cropper.vue';
|
||||||
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import { isStrongPassword } from '#/utils/password-policy';
|
||||||
|
|
||||||
const emit = defineEmits(['reload']);
|
const emit = defineEmits(['reload']);
|
||||||
// vue
|
// vue
|
||||||
@@ -23,7 +24,8 @@ const saveForm = ref<FormInstance>();
|
|||||||
// variables
|
// variables
|
||||||
const dialogVisible = ref(false);
|
const dialogVisible = ref(false);
|
||||||
const isAdd = ref(true);
|
const isAdd = ref(true);
|
||||||
const entity = ref<any>({
|
function createDefaultEntity() {
|
||||||
|
return {
|
||||||
deptId: '',
|
deptId: '',
|
||||||
loginName: '',
|
loginName: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -34,10 +36,24 @@ const entity = ref<any>({
|
|||||||
avatar: '',
|
avatar: '',
|
||||||
dataScope: '',
|
dataScope: '',
|
||||||
deptIdList: '',
|
deptIdList: '',
|
||||||
status: '',
|
status: 1,
|
||||||
remark: '',
|
remark: '',
|
||||||
positionIds: [],
|
positionIds: [],
|
||||||
});
|
roleIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const entity = ref<any>(createDefaultEntity());
|
||||||
|
const validateStrongPassword = (_rule: any, value: string, callback: any) => {
|
||||||
|
if (!value) {
|
||||||
|
callback(new Error($t('message.required')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isStrongPassword(value)) {
|
||||||
|
callback(new Error($t('sysAccount.passwordStrongTip')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
};
|
||||||
const btnLoading = ref(false);
|
const btnLoading = ref(false);
|
||||||
const rules = ref({
|
const rules = ref({
|
||||||
deptId: [
|
deptId: [
|
||||||
@@ -50,7 +66,7 @@ const rules = ref({
|
|||||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||||
],
|
],
|
||||||
password: [
|
password: [
|
||||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
{ required: true, validator: validateStrongPassword, trigger: 'blur' },
|
||||||
],
|
],
|
||||||
status: [
|
status: [
|
||||||
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
{ required: true, message: $t('message.required'), trigger: 'blur' },
|
||||||
@@ -58,10 +74,17 @@ const rules = ref({
|
|||||||
});
|
});
|
||||||
// functions
|
// functions
|
||||||
function openDialog(row: any) {
|
function openDialog(row: any) {
|
||||||
if (row.id) {
|
isAdd.value = !row?.id;
|
||||||
isAdd.value = false;
|
entity.value = {
|
||||||
|
...createDefaultEntity(),
|
||||||
|
...row,
|
||||||
|
};
|
||||||
|
if (!Array.isArray(entity.value.roleIds)) {
|
||||||
|
entity.value.roleIds = [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(entity.value.positionIds)) {
|
||||||
|
entity.value.positionIds = [];
|
||||||
}
|
}
|
||||||
entity.value = row;
|
|
||||||
dialogVisible.value = true;
|
dialogVisible.value = true;
|
||||||
}
|
}
|
||||||
function save() {
|
function save() {
|
||||||
@@ -90,7 +113,7 @@ function save() {
|
|||||||
function closeDialog() {
|
function closeDialog() {
|
||||||
saveForm.value?.resetFields();
|
saveForm.value?.resetFields();
|
||||||
isAdd.value = true;
|
isAdd.value = true;
|
||||||
entity.value = {};
|
entity.value = createDefaultEntity();
|
||||||
dialogVisible.value = false;
|
dialogVisible.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -129,7 +152,15 @@ function closeDialog() {
|
|||||||
prop="password"
|
prop="password"
|
||||||
:label="$t('sysAccount.password')"
|
:label="$t('sysAccount.password')"
|
||||||
>
|
>
|
||||||
<ElInput v-model.trim="entity.password" />
|
<div class="w-full">
|
||||||
|
<EasyFlowInputPassword
|
||||||
|
v-model="entity.password"
|
||||||
|
password-strength
|
||||||
|
/>
|
||||||
|
<div class="mt-2 text-xs text-[var(--el-text-color-secondary)]">
|
||||||
|
{{ $t('sysAccount.passwordStrongTip') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem prop="nickname" :label="$t('sysAccount.nickname')">
|
<ElFormItem prop="nickname" :label="$t('sysAccount.nickname')">
|
||||||
<ElInput v-model.trim="entity.nickname" />
|
<ElInput v-model.trim="entity.nickname" />
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ interface UserInfo extends BasicUserInfo {
|
|||||||
* accessToken
|
* accessToken
|
||||||
*/
|
*/
|
||||||
token: string;
|
token: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否需要重置密码
|
||||||
|
*/
|
||||||
|
passwordResetRequired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { UserInfo };
|
export type { UserInfo };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export namespace AuthApi {
|
|||||||
/** 登录接口返回值 */
|
/** 登录接口返回值 */
|
||||||
export interface LoginResult {
|
export interface LoginResult {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
forceChangePassword?: boolean;
|
||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ import { requestClient } from '#/api/request';
|
|||||||
* 获取用户信息
|
* 获取用户信息
|
||||||
*/
|
*/
|
||||||
export async function getUserInfoApi() {
|
export async function getUserInfoApi() {
|
||||||
return requestClient.get<UserInfo>('/api/v1/sysAccount/myProfile');
|
return requestClient.get<UserInfo>('/userCenter/sysAccount/myProfile');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,5 +23,7 @@
|
|||||||
"newPwd": "NewPassword",
|
"newPwd": "NewPassword",
|
||||||
"confirmPwd": "ConfirmPassword",
|
"confirmPwd": "ConfirmPassword",
|
||||||
"repeatPwd": "Please confirm your password again",
|
"repeatPwd": "Please confirm your password again",
|
||||||
"notSamePwd": "The two passwords are inconsistent"
|
"notSamePwd": "The two passwords are inconsistent",
|
||||||
|
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
||||||
|
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,5 +23,7 @@
|
|||||||
"newPwd": "新密码",
|
"newPwd": "新密码",
|
||||||
"confirmPwd": "确认密码",
|
"confirmPwd": "确认密码",
|
||||||
"repeatPwd": "请再次输入密码",
|
"repeatPwd": "请再次输入密码",
|
||||||
"notSamePwd": "两次输入的密码不一致"
|
"notSamePwd": "两次输入的密码不一致",
|
||||||
|
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||||
|
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { startProgress, stopProgress } from '@easyflow/utils';
|
|||||||
|
|
||||||
import { accessRoutes, coreRouteNames } from '#/router/routes';
|
import { accessRoutes, coreRouteNames } from '#/router/routes';
|
||||||
import { useAuthStore } from '#/store';
|
import { useAuthStore } from '#/store';
|
||||||
|
import {
|
||||||
|
buildForcePasswordRoute,
|
||||||
|
isForcePasswordRoute,
|
||||||
|
notifyForcePasswordChange,
|
||||||
|
shouldForcePasswordChange,
|
||||||
|
} from '#/utils/password-reset';
|
||||||
|
|
||||||
import { generateAccess } from './access';
|
import { generateAccess } from './access';
|
||||||
|
|
||||||
@@ -53,6 +59,10 @@ function setupAccessGuard(router: Router) {
|
|||||||
// 基本路由,这些路由不需要进入权限拦截
|
// 基本路由,这些路由不需要进入权限拦截
|
||||||
if (coreRouteNames.includes(to.name as string)) {
|
if (coreRouteNames.includes(to.name as string)) {
|
||||||
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
if (to.path === LOGIN_PATH && accessStore.accessToken) {
|
||||||
|
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||||
|
if (shouldForcePasswordChange(currentUser)) {
|
||||||
|
return buildForcePasswordRoute();
|
||||||
|
}
|
||||||
return decodeURIComponent(
|
return decodeURIComponent(
|
||||||
(to.query?.redirect as string) ||
|
(to.query?.redirect as string) ||
|
||||||
userStore.userInfo?.homePath ||
|
userStore.userInfo?.homePath ||
|
||||||
@@ -85,6 +95,14 @@ function setupAccessGuard(router: Router) {
|
|||||||
return to;
|
return to;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
||||||
|
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
|
||||||
|
if (from.name) {
|
||||||
|
notifyForcePasswordChange();
|
||||||
|
}
|
||||||
|
return buildForcePasswordRoute();
|
||||||
|
}
|
||||||
|
|
||||||
// 是否已经生成过动态路由
|
// 是否已经生成过动态路由
|
||||||
if (accessStore.isAccessChecked) {
|
if (accessStore.isAccessChecked) {
|
||||||
return true;
|
return true;
|
||||||
@@ -92,7 +110,6 @@ function setupAccessGuard(router: Router) {
|
|||||||
|
|
||||||
// 生成路由表
|
// 生成路由表
|
||||||
// 当前登录用户拥有的角色标识列表
|
// 当前登录用户拥有的角色标识列表
|
||||||
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
|
|
||||||
const userRoles = userInfo.roles ?? [];
|
const userRoles = userInfo.roles ?? [];
|
||||||
|
|
||||||
// 生成菜单和路由
|
// 生成菜单和路由
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { defineStore } from 'pinia';
|
|||||||
|
|
||||||
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
|
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
|
import {
|
||||||
|
buildForcePasswordRoute,
|
||||||
|
shouldForcePasswordChange,
|
||||||
|
} from '#/utils/password-reset';
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const accessStore = useAccessStore();
|
const accessStore = useAccessStore();
|
||||||
@@ -20,39 +24,34 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const loginLoading = ref(false);
|
const loginLoading = ref(false);
|
||||||
|
|
||||||
/**
|
async function finalizeLogin(
|
||||||
* 异步处理登录操作
|
accessToken: string,
|
||||||
* Asynchronously handle the login process
|
forceChangePassword?: boolean,
|
||||||
* @param params 登录表单数据
|
|
||||||
*/
|
|
||||||
async function authLogin(
|
|
||||||
params: Recordable<any>,
|
|
||||||
onSuccess?: () => Promise<void> | void,
|
onSuccess?: () => Promise<void> | void,
|
||||||
) {
|
) {
|
||||||
// 异步处理用户登录操作并获取 accessToken
|
|
||||||
let userInfo: null | UserInfo = null;
|
let userInfo: null | UserInfo = null;
|
||||||
try {
|
|
||||||
loginLoading.value = true;
|
|
||||||
const { token: accessToken } = await loginApi(params);
|
|
||||||
|
|
||||||
// 如果成功获取到 accessToken
|
|
||||||
if (accessToken) {
|
|
||||||
// 将 accessToken 存储到 accessStore 中
|
|
||||||
accessStore.setAccessToken(accessToken);
|
accessStore.setAccessToken(accessToken);
|
||||||
|
|
||||||
// 获取用户信息并存储到 accessStore 中
|
|
||||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||||
fetchUserInfo(),
|
fetchUserInfo(),
|
||||||
getAccessCodesApi(),
|
getAccessCodesApi(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
userInfo = fetchUserInfoResult;
|
userInfo = fetchUserInfoResult;
|
||||||
|
|
||||||
userStore.setUserInfo(userInfo);
|
userStore.setUserInfo(userInfo);
|
||||||
accessStore.setAccessCodes(accessCodes);
|
accessStore.setAccessCodes(accessCodes);
|
||||||
|
|
||||||
|
const forcePasswordChange = shouldForcePasswordChange(
|
||||||
|
userInfo,
|
||||||
|
forceChangePassword,
|
||||||
|
);
|
||||||
|
|
||||||
if (accessStore.loginExpired) {
|
if (accessStore.loginExpired) {
|
||||||
accessStore.setLoginExpired(false);
|
accessStore.setLoginExpired(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forcePasswordChange) {
|
||||||
|
await router.push(buildForcePasswordRoute());
|
||||||
} else {
|
} else {
|
||||||
onSuccess
|
onSuccess
|
||||||
? await onSuccess?.()
|
? await onSuccess?.()
|
||||||
@@ -68,13 +67,34 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步处理登录操作
|
||||||
|
* Asynchronously handle the login process
|
||||||
|
* @param params 登录表单数据
|
||||||
|
*/
|
||||||
|
async function authLogin(
|
||||||
|
params: Recordable<any>,
|
||||||
|
onSuccess?: () => Promise<void> | void,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
loginLoading.value = true;
|
||||||
|
const { forceChangePassword, token: accessToken } = await loginApi(params);
|
||||||
|
|
||||||
|
if (accessToken) {
|
||||||
|
return await finalizeLogin(accessToken, forceChangePassword, onSuccess);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loginLoading.value = false;
|
loginLoading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userInfo,
|
userInfo: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
easyflow-ui-usercenter/app/src/utils/password-policy.ts
Normal file
6
easyflow-ui-usercenter/app/src/utils/password-policy.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const strongPasswordPattern =
|
||||||
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/;
|
||||||
|
|
||||||
|
export function isStrongPassword(value?: string) {
|
||||||
|
return !!value && strongPasswordPattern.test(value);
|
||||||
|
}
|
||||||
52
easyflow-ui-usercenter/app/src/utils/password-reset.ts
Normal file
52
easyflow-ui-usercenter/app/src/utils/password-reset.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Router } from 'vue-router';
|
||||||
|
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
|
||||||
|
|
||||||
|
let lastForcePasswordNoticeAt = 0;
|
||||||
|
|
||||||
|
export function buildForcePasswordRoute() {
|
||||||
|
return {
|
||||||
|
name: 'Profile',
|
||||||
|
query: {
|
||||||
|
force: '1',
|
||||||
|
tab: 'password',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isForcePasswordRoute(
|
||||||
|
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>,
|
||||||
|
) {
|
||||||
|
return route.name === 'Profile' && route.query?.tab === 'password';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldForcePasswordChange(
|
||||||
|
userInfo?: null | Record<string, any>,
|
||||||
|
forceChangePassword?: boolean,
|
||||||
|
) {
|
||||||
|
return !!forceChangePassword || !!userInfo?.passwordResetRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveForcePasswordPath(router: Router) {
|
||||||
|
return router.resolve(buildForcePasswordRoute()).fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyForcePasswordChange() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastForcePasswordNoticeAt < FORCE_PASSWORD_NOTICE_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastForcePasswordNoticeAt = now;
|
||||||
|
ElMessage({
|
||||||
|
message: $t('sysAccount.forceChangePasswordNavigateTip'),
|
||||||
|
type: 'warning',
|
||||||
|
duration: 2200,
|
||||||
|
grouping: true,
|
||||||
|
plain: true,
|
||||||
|
showClose: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,19 +1,36 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import { Profile } from '@easyflow/common-ui';
|
import { Profile } from '@easyflow/common-ui';
|
||||||
import { useUserStore } from '@easyflow/stores';
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
import ProfileBase from './base-setting.vue';
|
import ProfileBase from './base-setting.vue';
|
||||||
import ProfileNotificationSetting from './notification-setting.vue';
|
import ProfileNotificationSetting from './notification-setting.vue';
|
||||||
import ProfilePasswordSetting from './password-setting.vue';
|
import ProfilePasswordSetting from './password-setting.vue';
|
||||||
import ProfileSecuritySetting from './security-setting.vue';
|
import ProfileSecuritySetting from './security-setting.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
const tabsValue = ref<string>('basic');
|
const tabsValue = ref<string>('basic');
|
||||||
|
|
||||||
const tabs = ref([
|
const forcePasswordChange = computed(() => {
|
||||||
|
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = computed(() => {
|
||||||
|
if (forcePasswordChange.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: $t('settingsConfig.updatePwd'),
|
||||||
|
value: 'password',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
{
|
{
|
||||||
label: '基本设置',
|
label: '基本设置',
|
||||||
value: 'basic',
|
value: 'basic',
|
||||||
@@ -30,7 +47,20 @@ const tabs = ref([
|
|||||||
label: '新消息提醒',
|
label: '新消息提醒',
|
||||||
value: 'notice',
|
value: 'notice',
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.query.force, route.query.tab, userStore.userInfo?.passwordResetRequired],
|
||||||
|
() => {
|
||||||
|
if (forcePasswordChange.value) {
|
||||||
|
tabsValue.value = 'password';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabsValue.value = (route.query.tab as string) || 'basic';
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Profile
|
<Profile
|
||||||
|
|||||||
@@ -2,48 +2,75 @@
|
|||||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||||
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
||||||
|
import { preferences } from '@easyflow/preferences';
|
||||||
|
import { useUserStore } from '@easyflow/stores';
|
||||||
|
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { api } from '#/api/request';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
import { useAuthStore } from '#/store';
|
||||||
|
import { isStrongPassword } from '#/utils/password-policy';
|
||||||
|
|
||||||
const profilePasswordSettingRef = ref();
|
const profilePasswordSettingRef = ref();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const isForcedPasswordChange = computed(() => {
|
||||||
|
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
|
||||||
|
});
|
||||||
|
|
||||||
const formSchema = computed((): EasyFlowFormSchema[] => {
|
const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
fieldName: 'oldPassword',
|
fieldName: 'password',
|
||||||
label: '旧密码',
|
label: $t('sysAccount.oldPwd'),
|
||||||
component: 'EasyFlowInputPassword',
|
component: 'EasyFlowInputPassword',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入旧密码',
|
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'newPassword',
|
fieldName: 'newPassword',
|
||||||
label: '新密码',
|
label: $t('sysAccount.newPwd'),
|
||||||
component: 'EasyFlowInputPassword',
|
component: 'EasyFlowInputPassword',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
passwordStrength: true,
|
passwordStrength: true,
|
||||||
placeholder: '请输入新密码',
|
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
|
||||||
},
|
},
|
||||||
|
renderComponentContent() {
|
||||||
|
return {
|
||||||
|
strengthText: () => $t('sysAccount.passwordStrongTip'),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
rules: z
|
||||||
|
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||||
|
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||||
|
.refine((value) => isStrongPassword(value), {
|
||||||
|
message: $t('sysAccount.passwordStrongTip'),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'confirmPassword',
|
fieldName: 'confirmPassword',
|
||||||
label: '确认密码',
|
label: $t('sysAccount.confirmPwd'),
|
||||||
component: 'EasyFlowInputPassword',
|
component: 'EasyFlowInputPassword',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
passwordStrength: true,
|
passwordStrength: true,
|
||||||
placeholder: '请再次输入新密码',
|
placeholder: $t('sysAccount.repeatPwd'),
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
rules(values) {
|
rules(values) {
|
||||||
const { newPassword } = values;
|
const { newPassword } = values;
|
||||||
return z
|
return z
|
||||||
.string({ required_error: '请再次输入新密码' })
|
.string({ required_error: $t('sysAccount.repeatPwd') })
|
||||||
.min(1, { message: '请再次输入新密码' })
|
.min(1, { message: $t('sysAccount.repeatPwd') })
|
||||||
.refine((value) => value === newPassword, {
|
.refine((value) => value === newPassword, {
|
||||||
message: '两次输入的密码不一致',
|
message: $t('sysAccount.notSamePwd'),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
triggerFields: ['newPassword'],
|
triggerFields: ['newPassword'],
|
||||||
@@ -52,12 +79,29 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleSubmit() {
|
const updateLoading = ref(false);
|
||||||
ElMessage.success('密码修改成功');
|
async function handleSubmit(values: any) {
|
||||||
|
updateLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.post('/userCenter/sysAccount/updatePassword', values);
|
||||||
|
if (res.errorCode === 0) {
|
||||||
|
ElMessage.success($t('message.success'));
|
||||||
|
const userInfo = await authStore.fetchUserInfo();
|
||||||
|
if (isForcedPasswordChange.value) {
|
||||||
|
await router.replace(
|
||||||
|
userInfo?.homePath || preferences.app.defaultHomePath || '/',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
updateLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<ProfilePasswordSetting
|
<ProfilePasswordSetting
|
||||||
|
:button-loading="updateLoading"
|
||||||
|
:button-text="$t('button.update')"
|
||||||
ref="profilePasswordSettingRef"
|
ref="profilePasswordSettingRef"
|
||||||
class="w-1/3"
|
class="w-1/3"
|
||||||
:form-schema="formSchema"
|
:form-schema="formSchema"
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ interface UserInfo extends BasicUserInfo {
|
|||||||
* accessToken
|
* accessToken
|
||||||
*/
|
*/
|
||||||
token: string;
|
token: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否需要重置密码
|
||||||
|
*/
|
||||||
|
passwordResetRequired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { UserInfo };
|
export type { UserInfo };
|
||||||
|
|||||||
@@ -735,6 +735,7 @@ CREATE TABLE `tb_sys_account`
|
|||||||
`tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID',
|
`tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
`login_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录账号',
|
`login_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录账号',
|
||||||
`password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
|
`password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
|
||||||
|
`password_reset_required` tinyint NOT NULL DEFAULT 0 COMMENT '是否需要重置密码',
|
||||||
`account_type` tinyint NOT NULL DEFAULT 0 COMMENT '账户类型',
|
`account_type` tinyint NOT NULL DEFAULT 0 COMMENT '账户类型',
|
||||||
`nickname` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '昵称',
|
`nickname` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '昵称',
|
||||||
`mobile` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '手机电话',
|
`mobile` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '手机电话',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ SET NAMES utf8mb4;
|
|||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Records of tb_sys_account
|
-- Records of tb_sys_account
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
INSERT INTO `tb_sys_account` (`id`, `dept_id`, `tenant_id`, `login_name`, `password`, `account_type`, `nickname`, `mobile`, `email`, `avatar`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES (1, 1, 1000000, 'admin', '$2a$10$mni2UdHMUwomVzvZtdECAOwYJevZ2z48ApO.JSVhyEaQ/AOKr4VP2', 99, '超级管理员', '15555555555', 'bbb@qq.com', 'https://static.agentscenter.cn/public/1/2025/12/17/684ea528-8e42-489c-b254-4e52e0679431/b.jpeg', 1, '2025-06-06 11:32:21', 1, '2025-12-17 17:51:16', 1, '');
|
INSERT INTO `tb_sys_account` (`id`, `dept_id`, `tenant_id`, `login_name`, `password`, `password_reset_required`, `account_type`, `nickname`, `mobile`, `email`, `avatar`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES (1, 1, 1000000, 'admin', '$2a$10$mni2UdHMUwomVzvZtdECAOwYJevZ2z48ApO.JSVhyEaQ/AOKr4VP2', 0, 99, '超级管理员', '15555555555', 'bbb@qq.com', 'https://static.agentscenter.cn/public/1/2025/12/17/684ea528-8e42-489c-b254-4e52e0679431/b.jpeg', 1, '2025-06-06 11:32:21', 1, '2025-12-17 17:51:16', 1, '');
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Records of tb_sys_account_role
|
-- Records of tb_sys_account_role
|
||||||
|
|||||||
2
sql/05-easyflow-v2.p2-account-password-reset.sql
Normal file
2
sql/05-easyflow-v2.p2-account-password-reset.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE tb_sys_account
|
||||||
|
ADD COLUMN password_reset_required tinyint NOT NULL DEFAULT 0 COMMENT '是否需要重置密码' AFTER password;
|
||||||
@@ -735,6 +735,7 @@ CREATE TABLE `tb_sys_account`
|
|||||||
`tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID',
|
`tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID',
|
||||||
`login_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录账号',
|
`login_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '登录账号',
|
||||||
`password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
|
`password` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '密码',
|
||||||
|
`password_reset_required` tinyint NOT NULL DEFAULT 0 COMMENT '是否需要重置密码',
|
||||||
`account_type` tinyint NOT NULL DEFAULT 0 COMMENT '账户类型',
|
`account_type` tinyint NOT NULL DEFAULT 0 COMMENT '账户类型',
|
||||||
`nickname` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '昵称',
|
`nickname` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '昵称',
|
||||||
`mobile` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '手机电话',
|
`mobile` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '手机电话',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ SET NAMES utf8mb4;
|
|||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Records of tb_sys_account
|
-- Records of tb_sys_account
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
INSERT INTO `tb_sys_account` (`id`, `dept_id`, `tenant_id`, `login_name`, `password`, `account_type`, `nickname`, `mobile`, `email`, `avatar`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES (1, 1, 1000000, 'admin', '$2a$10$mni2UdHMUwomVzvZtdECAOwYJevZ2z48ApO.JSVhyEaQ/AOKr4VP2', 99, '超级管理员', '15555555555', 'bbb@qq.com', 'https://static.agentscenter.cn/public/1/2025/12/17/684ea528-8e42-489c-b254-4e52e0679431/b.jpeg', 1, '2025-06-06 11:32:21', 1, '2025-12-17 17:51:16', 1, '');
|
INSERT INTO `tb_sys_account` (`id`, `dept_id`, `tenant_id`, `login_name`, `password`, `password_reset_required`, `account_type`, `nickname`, `mobile`, `email`, `avatar`, `status`, `created`, `created_by`, `modified`, `modified_by`, `remark`) VALUES (1, 1, 1000000, 'admin', '$2a$10$mni2UdHMUwomVzvZtdECAOwYJevZ2z48ApO.JSVhyEaQ/AOKr4VP2', 0, 99, '超级管理员', '15555555555', 'bbb@qq.com', 'https://static.agentscenter.cn/public/1/2025/12/17/684ea528-8e42-489c-b254-4e52e0679431/b.jpeg', 1, '2025-06-06 11:32:21', 1, '2025-12-17 17:51:16', 1, '');
|
||||||
|
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
-- Records of tb_sys_account_role
|
-- Records of tb_sys_account_role
|
||||||
|
|||||||
Reference in New Issue
Block a user