feat: 支持账号导入与强制改密

- 新增账号导入模板下载、导入校验和默认密码重置标记

- 支持管理员重置密码并在登录后强制跳转修改密码

- 管理端与用户中心接入强密码校验和密码重置流程
This commit is contained in:
2026-03-18 21:56:05 +08:00
parent 14c78d54f5
commit 5d3c7d8692
40 changed files with 1720 additions and 142 deletions

View File

@@ -1,27 +1,32 @@
package tech.easyflow.admin.controller.system;
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.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.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.mybatisflex.core.paginate.Page;
import com.mybatisflex.core.query.QueryWrapper;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
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.math.BigInteger;
import java.util.Collection;
@@ -62,13 +67,21 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
return Result.fail(1, "用户名已存在");
}
String password = entity.getPassword();
if (StringUtil.hasText(password)) {
entity.setPassword(BCrypt.hashpw(password));
if (!StringUtil.hasText(password)) {
return Result.fail(1, "密码不能为空");
}
SysPasswordPolicy.validateStrongPassword(password);
entity.setPassword(BCrypt.hashpw(password));
Integer status = entity.getStatus();
if (status == null) {
entity.setStatus(EnumDataStatus.AVAILABLE.getCode());
}
if (entity.getAccountType() == null) {
entity.setAccountType(EnumAccountType.NORMAL.getCode());
}
if (entity.getPasswordResetRequired() == null) {
entity.setPasswordResetRequired(false);
}
} else {
SysAccount record = service.getById(entity.getId());
// 如果修改了部门,就将用户踢下线,避免用户操作数据造成数据错误
@@ -149,15 +162,40 @@ public class SysAccountController extends BaseCurdController<SysAccountService,
if (!newPassword.equals(confirmPassword)) {
return Result.fail(2, "两次密码不一致");
}
SysPasswordPolicy.validateStrongPassword(newPassword);
SysAccount update = new SysAccount();
update.setId(loginAccountId);
update.setPassword(BCrypt.hashpw(newPassword));
update.setPasswordResetRequired(false);
update.setModified(new Date());
update.setModifiedBy(loginAccountId);
service.updateById(update);
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
@PostMapping("save")
public Result<?> save(@JsonBody SysAccount entity) {

View File

@@ -11,6 +11,7 @@ import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.jsonbody.JsonBody;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.service.SysAccountService;
import tech.easyflow.system.util.SysPasswordPolicy;
import javax.annotation.Resource;
import java.math.BigInteger;
@@ -80,9 +81,11 @@ public class UcSysAccountController {
if (!newPassword.equals(confirmPassword)) {
return Result.fail(2, "两次密码不一致");
}
SysPasswordPolicy.validateStrongPassword(newPassword);
SysAccount update = new SysAccount();
update.setId(loginAccountId);
update.setPassword(BCrypt.hashpw(newPassword));
update.setPasswordResetRequired(false);
update.setModified(new Date());
update.setModifiedBy(loginAccountId);
service.updateById(update);

View File

@@ -15,6 +15,11 @@ public class LoginVO {
*/
private String avatar;
/**
* 是否强制修改密码
*/
private boolean forceChangePassword;
public String getToken() {
return token;
}
@@ -38,4 +43,12 @@ public class LoginVO {
public void setAvatar(String avatar) {
this.avatar = avatar;
}
public boolean isForceChangePassword() {
return forceChangePassword;
}
public void setForceChangePassword(boolean forceChangePassword) {
this.forceChangePassword = forceChangePassword;
}
}

View File

@@ -88,6 +88,7 @@ public class AuthServiceImpl implements AuthService, StpInterface {
res.setToken(StpUtil.getTokenValue());
res.setNickname(record.getNickname());
res.setAvatar(record.getAvatar());
res.setForceChangePassword(Boolean.TRUE.equals(record.getPasswordResetRequired()));
return res;
}

View File

@@ -43,6 +43,12 @@ public class SysAccountBase extends DateEntity implements Serializable {
@Column(comment = "密码")
private String password;
/**
* 是否需要重置密码
*/
@Column(comment = "是否需要重置密码")
private Boolean passwordResetRequired;
/**
* 账户类型
*/
@@ -149,6 +155,14 @@ public class SysAccountBase extends DateEntity implements Serializable {
this.password = password;
}
public Boolean getPasswordResetRequired() {
return passwordResetRequired;
}
public void setPasswordResetRequired(Boolean passwordResetRequired) {
this.passwordResetRequired = passwordResetRequired;
}
public Integer getAccountType() {
return accountType;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,7 +1,13 @@
package tech.easyflow.system.service;
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.vo.SysAccountImportResultVo;
import java.io.OutputStream;
import java.math.BigInteger;
/**
* 用户表 服务层。
@@ -14,4 +20,10 @@ public interface SysAccountService extends IService<SysAccount> {
void syncRelations(SysAccount entity);
SysAccount getByUsername(String userKey);
void resetPassword(BigInteger accountId, BigInteger operatorId);
SysAccountImportResultVo importAccounts(MultipartFile file, LoginAccount loginAccount);
void writeImportTemplate(OutputStream outputStream);
}

View File

@@ -1,25 +1,54 @@
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.spring.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
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.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.SysAccountPosition;
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.SysAccountPositionMapper;
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.service.SysAccountService;
import javax.annotation.Resource;
import java.time.Duration;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.time.Duration;
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.List;
import java.util.Map;
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 Duration LOCK_WAIT_TIMEOUT = Duration.ofSeconds(2);
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
private SysAccountRoleMapper sysAccountRoleMapper;
@@ -42,7 +83,13 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
@Resource
private SysRoleMapper sysRoleMapper;
@Resource
private SysPositionMapper sysPositionMapper;
@Resource
private SysDeptMapper sysDeptMapper;
@Resource
private RedisLockExecutor redisLockExecutor;
@Resource
private PlatformTransactionManager transactionManager;
@Override
@Transactional(rollbackFor = Exception.class)
@@ -55,7 +102,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
LOCK_WAIT_TIMEOUT,
LOCK_LEASE_TIMEOUT,
() -> {
//sync roleIds
List<BigInteger> roleIds = entity.getRoleIds();
if (roleIds != null) {
QueryWrapper delW = QueryWrapper.create();
@@ -79,7 +125,6 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
}
}
//sync positionIds
List<BigInteger> positionIds = entity.getPositionIds();
if (positionIds != null) {
QueryWrapper delW = QueryWrapper.create();
@@ -112,4 +157,483 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
w.eq(SysAccount::getLoginName, userKey);
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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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");
}
}

View File

@@ -15,6 +15,7 @@ export namespace AuthApi {
/** 登录接口返回值 */
export interface LoginResult {
accessToken: string;
forceChangePassword?: boolean;
token: string;
}

View File

@@ -23,5 +23,23 @@
"newPwd": "NewPassword",
"confirmPwd": "ConfirmPassword",
"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"
}

View File

@@ -24,5 +24,23 @@
"newPwd": "新密码",
"confirmPwd": "确认密码",
"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": "失败原因"
}

View File

@@ -14,6 +14,12 @@ import {
removeDevLoginQuery,
shouldAttemptDevLogin,
} from './dev-login';
import {
buildForcePasswordRoute,
isForcePasswordRoute,
notifyForcePasswordChange,
shouldForcePasswordChange,
} from '#/utils/password-reset';
interface NetworkConnectionLike {
effectiveType?: string;
@@ -183,6 +189,10 @@ function setupAccessGuard(router: Router) {
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
if (shouldForcePasswordChange(currentUser)) {
return buildForcePasswordRoute();
}
return decodeURIComponent(
(to.query?.redirect as string) ||
userStore.userInfo?.homePath ||
@@ -219,6 +229,14 @@ function setupAccessGuard(router: Router) {
return to;
}
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
if (from.name) {
notifyForcePasswordChange();
}
return buildForcePasswordRoute();
}
// 是否已经生成过动态路由
if (accessStore.isAccessChecked) {
return true;
@@ -226,7 +244,6 @@ function setupAccessGuard(router: Router) {
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? [];
// 生成菜单和路由

View File

@@ -18,6 +18,10 @@ import {
logoutApi,
} from '#/api';
import { $t } from '#/locales';
import {
buildForcePasswordRoute,
shouldForcePasswordChange,
} from '#/utils/password-reset';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
@@ -28,6 +32,7 @@ export const useAuthStore = defineStore('auth', () => {
async function finalizeLogin(
accessToken: string,
forceChangePassword?: boolean,
options: {
notify?: boolean;
onSuccess?: () => Promise<void> | void;
@@ -47,8 +52,17 @@ export const useAuthStore = defineStore('auth', () => {
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
const forcePasswordChange = shouldForcePasswordChange(
userInfo,
forceChangePassword,
);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
}
if (forcePasswordChange) {
await router.push(buildForcePasswordRoute());
} else if (!options.skipRedirect) {
const homePath =
userInfo.homePath || preferences.app.defaultHomePath || '/';
@@ -81,10 +95,12 @@ export const useAuthStore = defineStore('auth', () => {
) {
try {
loginLoading.value = true;
const { token: accessToken } = await loginApi(params);
const { forceChangePassword, token: accessToken } = await loginApi(params);
if (accessToken) {
return await finalizeLogin(accessToken, { onSuccess });
return await finalizeLogin(accessToken, forceChangePassword, {
onSuccess,
});
}
} finally {
loginLoading.value = false;
@@ -96,13 +112,15 @@ export const useAuthStore = defineStore('auth', () => {
}
async function authDevLogin(account: string) {
const { token: accessToken } = await devLoginApi({ account });
const { forceChangePassword, token: accessToken } = await devLoginApi({
account,
});
if (!accessToken) {
return {
userInfo: null,
};
}
return finalizeLogin(accessToken, {
return finalizeLogin(accessToken, forceChangePassword, {
notify: false,
skipRedirect: true,
});

View 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);
}

View 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,
});
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Profile } from '@easyflow/common-ui';
@@ -17,7 +17,20 @@ const userStore = useUserStore();
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'),
value: 'basic',
@@ -26,13 +39,20 @@ const tabs = [
label: $t('settingsConfig.updatePwd'),
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>
<template>
<Profile
@@ -43,8 +63,8 @@ onMounted(() => {
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>

View File

@@ -2,15 +2,28 @@
import type { EasyFlowFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences';
import { useUserStore } from '@easyflow/stores';
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 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[] => {
return [
@@ -30,6 +43,17 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
passwordStrength: true,
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',
@@ -56,14 +80,22 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
});
const updateLoading = ref(false);
function handleSubmit(values: any) {
async function handleSubmit(values: any) {
updateLoading.value = true;
api.post('/api/v1/sysAccount/updatePassword', values).then((res) => {
updateLoading.value = false;
try {
const res = await api.post('/api/v1/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>
<template>

View File

@@ -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>

View File

@@ -3,7 +3,13 @@ import type { FormInstance } from 'element-plus';
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 {
ElAvatar,
ElButton,
@@ -24,6 +30,7 @@ import PageData from '#/components/page/PageData.vue';
import { $t } from '#/locales';
import { useDictStore } from '#/store';
import SysAccountImportModal from './SysAccountImportModal.vue';
import SysAccountModal from './SysAccountModal.vue';
onMounted(() => {
@@ -31,6 +38,7 @@ onMounted(() => {
});
const pageDataRef = ref();
const importDialog = ref();
const saveDialog = ref();
const dictStore = useDictStore();
const headerButtons = [
@@ -42,6 +50,13 @@ const headerButtons = [
data: { action: 'create' },
permission: '/api/v1/sysAccount/save',
},
{
key: 'import',
text: $t('button.import'),
icon: markRaw(Upload),
data: { action: 'import' },
permission: '/api/v1/sysAccount/save',
},
];
function initDict() {
@@ -57,6 +72,16 @@ function reset(formEl?: FormInstance) {
function showDialog(row: any) {
saveDialog.value.openDialog({ ...row });
}
function openImportDialog() {
importDialog.value.openDialog();
}
function handleHeaderButtonClick(payload: any) {
if (payload?.key === 'import') {
openImportDialog();
return;
}
showDialog({});
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
@@ -84,6 +109,36 @@ function remove(row: any) {
},
}).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) {
return data?.accountType === 1 || data?.accountType === 99;
}
@@ -91,13 +146,14 @@ function isAdmin(data: any) {
<template>
<div class="flex h-full flex-col gap-6 p-6">
<SysAccountImportModal ref="importDialog" @reload="reset" />
<SysAccountModal ref="saveDialog" @reload="reset" />
<ListPageShell>
<template #filters>
<HeaderSearch
:buttons="headerButtons"
@search="handleSearch"
@button-click="showDialog({})"
@button-click="handleHeaderButtonClick"
/>
</template>
<PageData
@@ -193,6 +249,13 @@ function isAdmin(data: any) {
<template #dropdown>
<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'">
<ElDropdownItem @click="remove(row)">
<ElButton type="danger" :icon="Delete" link>

View File

@@ -3,7 +3,7 @@ import type { FormInstance } from 'element-plus';
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';
@@ -12,6 +12,7 @@ import DictSelect from '#/components/dict/DictSelect.vue';
// import Cropper from '#/components/upload/Cropper.vue';
import UploadAvatar from '#/components/upload/UploadAvatar.vue';
import { $t } from '#/locales';
import { isStrongPassword } from '#/utils/password-policy';
const emit = defineEmits(['reload']);
// vue
@@ -23,7 +24,8 @@ const saveForm = ref<FormInstance>();
// variables
const dialogVisible = ref(false);
const isAdd = ref(true);
const entity = ref<any>({
function createDefaultEntity() {
return {
deptId: '',
loginName: '',
password: '',
@@ -34,10 +36,24 @@ const entity = ref<any>({
avatar: '',
dataScope: '',
deptIdList: '',
status: '',
status: 1,
remark: '',
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 rules = ref({
deptId: [
@@ -50,7 +66,7 @@ const rules = ref({
{ required: true, message: $t('message.required'), trigger: 'blur' },
],
password: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
{ required: true, validator: validateStrongPassword, trigger: 'blur' },
],
status: [
{ required: true, message: $t('message.required'), trigger: 'blur' },
@@ -58,10 +74,17 @@ const rules = ref({
});
// functions
function openDialog(row: any) {
if (row.id) {
isAdd.value = false;
isAdd.value = !row?.id;
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;
}
function save() {
@@ -90,7 +113,7 @@ function save() {
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = {};
entity.value = createDefaultEntity();
dialogVisible.value = false;
}
</script>
@@ -129,7 +152,15 @@ function closeDialog() {
prop="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 prop="nickname" :label="$t('sysAccount.nickname')">
<ElInput v-model.trim="entity.nickname" />

View File

@@ -15,6 +15,11 @@ interface UserInfo extends BasicUserInfo {
* accessToken
*/
token: string;
/**
* 是否需要重置密码
*/
passwordResetRequired?: boolean;
}
export type { UserInfo };

View File

@@ -10,6 +10,7 @@ export namespace AuthApi {
/** 登录接口返回值 */
export interface LoginResult {
accessToken: string;
forceChangePassword?: boolean;
token: string;
}

View File

@@ -6,5 +6,5 @@ import { requestClient } from '#/api/request';
* 获取用户信息
*/
export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/api/v1/sysAccount/myProfile');
return requestClient.get<UserInfo>('/userCenter/sysAccount/myProfile');
}

View File

@@ -23,5 +23,7 @@
"newPwd": "NewPassword",
"confirmPwd": "ConfirmPassword",
"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."
}

View File

@@ -23,5 +23,7 @@
"newPwd": "新密码",
"confirmPwd": "确认密码",
"repeatPwd": "请再次输入密码",
"notSamePwd": "两次输入的密码不一致"
"notSamePwd": "两次输入的密码不一致",
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面"
}

View File

@@ -7,6 +7,12 @@ import { startProgress, stopProgress } from '@easyflow/utils';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import {
buildForcePasswordRoute,
isForcePasswordRoute,
notifyForcePasswordChange,
shouldForcePasswordChange,
} from '#/utils/password-reset';
import { generateAccess } from './access';
@@ -53,6 +59,10 @@ function setupAccessGuard(router: Router) {
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
if (shouldForcePasswordChange(currentUser)) {
return buildForcePasswordRoute();
}
return decodeURIComponent(
(to.query?.redirect as string) ||
userStore.userInfo?.homePath ||
@@ -85,6 +95,14 @@ function setupAccessGuard(router: Router) {
return to;
}
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
if (from.name) {
notifyForcePasswordChange();
}
return buildForcePasswordRoute();
}
// 是否已经生成过动态路由
if (accessStore.isAccessChecked) {
return true;
@@ -92,7 +110,6 @@ function setupAccessGuard(router: Router) {
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? [];
// 生成菜单和路由

View File

@@ -12,6 +12,10 @@ import { defineStore } from 'pinia';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
import { $t } from '#/locales';
import {
buildForcePasswordRoute,
shouldForcePasswordChange,
} from '#/utils/password-reset';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
@@ -20,39 +24,34 @@ export const useAuthStore = defineStore('auth', () => {
const loginLoading = ref(false);
/**
* 异步处理登录操作
* Asynchronously handle the login process
* @param params 登录表单数据
*/
async function authLogin(
params: Recordable<any>,
async function finalizeLogin(
accessToken: string,
forceChangePassword?: boolean,
onSuccess?: () => Promise<void> | void,
) {
// 异步处理用户登录操作并获取 accessToken
let userInfo: null | UserInfo = null;
try {
loginLoading.value = true;
const { token: accessToken } = await loginApi(params);
// 如果成功获取到 accessToken
if (accessToken) {
// 将 accessToken 存储到 accessStore 中
accessStore.setAccessToken(accessToken);
// 获取用户信息并存储到 accessStore 中
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
const forcePasswordChange = shouldForcePasswordChange(
userInfo,
forceChangePassword,
);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
}
if (forcePasswordChange) {
await router.push(buildForcePasswordRoute());
} else {
onSuccess
? await onSuccess?.()
@@ -68,13 +67,34 @@ export const useAuthStore = defineStore('auth', () => {
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 {
loginLoading.value = false;
}
return {
userInfo,
userInfo: null,
};
}

View 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);
}

View 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,
});
}

View File

@@ -1,19 +1,36 @@
<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 { useUserStore } from '@easyflow/stores';
import { $t } from '#/locales';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const route = useRoute();
const userStore = useUserStore();
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: '基本设置',
value: 'basic',
@@ -30,7 +47,20 @@ const tabs = ref([
label: '新消息提醒',
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>
<template>
<Profile

View File

@@ -2,48 +2,75 @@
import type { EasyFlowFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences';
import { useUserStore } from '@easyflow/stores';
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 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[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
fieldName: 'password',
label: $t('sysAccount.oldPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: '请输入旧密码',
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
},
},
{
fieldName: 'newPassword',
label: '新密码',
label: $t('sysAccount.newPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
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',
label: '确认密码',
label: $t('sysAccount.confirmPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
placeholder: $t('sysAccount.repeatPwd'),
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.string({ required_error: $t('sysAccount.repeatPwd') })
.min(1, { message: $t('sysAccount.repeatPwd') })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
message: $t('sysAccount.notSamePwd'),
});
},
triggerFields: ['newPassword'],
@@ -52,12 +79,29 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
];
});
function handleSubmit() {
ElMessage.success('密码修改成功');
const updateLoading = ref(false);
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>
<template>
<ProfilePasswordSetting
:button-loading="updateLoading"
:button-text="$t('button.update')"
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"

View File

@@ -15,6 +15,11 @@ interface UserInfo extends BasicUserInfo {
* accessToken
*/
token: string;
/**
* 是否需要重置密码
*/
passwordResetRequired?: boolean;
}
export type { UserInfo };

View File

@@ -735,6 +735,7 @@ CREATE TABLE `tb_sys_account`
`tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID',
`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_reset_required` 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 '昵称',
`mobile` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '手机电话',

View File

@@ -3,7 +3,7 @@ SET NAMES utf8mb4;
-- ----------------------------
-- 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

View File

@@ -0,0 +1,2 @@
ALTER TABLE tb_sys_account
ADD COLUMN password_reset_required tinyint NOT NULL DEFAULT 0 COMMENT '是否需要重置密码' AFTER password;

View File

@@ -735,6 +735,7 @@ CREATE TABLE `tb_sys_account`
`tenant_id` bigint UNSIGNED NOT NULL COMMENT '租户ID',
`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_reset_required` 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 '昵称',
`mobile` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '' COMMENT '手机电话',

View File

@@ -3,7 +3,7 @@ SET NAMES utf8mb4;
-- ----------------------------
-- 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