feat: 用户操作日志.

This commit is contained in:
lijiahang
2023-11-01 18:57:53 +08:00
parent cfcb5cb7a8
commit eafe69ebca
45 changed files with 1255 additions and 157 deletions

View File

@@ -53,13 +53,13 @@ export interface ${vue.featureEntity}QueryRequest extends Pagination {
export interface ${vue.featureEntity}QueryResponse extends TableData { export interface ${vue.featureEntity}QueryResponse extends TableData {
#foreach($field in ${table.fields}) #foreach($field in ${table.fields})
#if("$field.propertyType" == "String") #if("$field.propertyType" == "String")
${field.propertyName}?: string; ${field.propertyName}: string;
#elseif("$field.propertyType" == "Integer" || "$field.propertyType" == "Long" || "$field.propertyType" == "Date") #elseif("$field.propertyType" == "Integer" || "$field.propertyType" == "Long" || "$field.propertyType" == "Date")
${field.propertyName}?: number; ${field.propertyName}: number;
#elseif("$field.propertyType" == "Boolean") #elseif("$field.propertyType" == "Boolean")
${field.propertyName}?: boolean; ${field.propertyName}: boolean;
#else #else
${field.propertyName}?: any; ${field.propertyName}: any;
#end #end
#end #end
createTime: number; createTime: number;

View File

@@ -9,8 +9,8 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.ScanOptions;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Set; import java.util.Set;
/** /**
@@ -92,7 +92,7 @@ public class RedisUtils {
* *
* @param keys keys * @param keys keys
*/ */
public static void delete(List<String> keys) { public static void delete(Collection<String> keys) {
redisTemplate.delete(keys); redisTemplate.delete(keys);
} }

View File

@@ -161,7 +161,7 @@ orion:
# 下面引用了 需要注意 # 下面引用了 需要注意
field: field:
ignore: ignore:
- password,beforePassword,newPassword,useNewPassword,publicKey,privateKey - password,beforePassword,newPassword,useNewPassword,publicKey,privateKey,token
- metrics - metrics
desensitize: desensitize:
storage: storage:

View File

@@ -6,7 +6,7 @@ import com.orion.ops.framework.log.core.annotation.IgnoreLog;
import com.orion.ops.framework.log.core.enums.IgnoreLogMode; import com.orion.ops.framework.log.core.enums.IgnoreLogMode;
import com.orion.ops.framework.web.core.annotation.RestWrapper; import com.orion.ops.framework.web.core.annotation.RestWrapper;
import com.orion.ops.module.infra.define.operator.AuthenticationOperatorType; import com.orion.ops.module.infra.define.operator.AuthenticationOperatorType;
import com.orion.ops.module.infra.entity.request.user.OfflineUserSessionRequest; import com.orion.ops.module.infra.entity.request.user.UserSessionOfflineRequest;
import com.orion.ops.module.infra.entity.request.user.SystemUserUpdateRequest; import com.orion.ops.module.infra.entity.request.user.SystemUserUpdateRequest;
import com.orion.ops.module.infra.entity.request.user.UserUpdatePasswordRequest; import com.orion.ops.module.infra.entity.request.user.UserUpdatePasswordRequest;
import com.orion.ops.module.infra.entity.vo.LoginHistoryVO; import com.orion.ops.module.infra.entity.vo.LoginHistoryVO;
@@ -79,12 +79,11 @@ public class MineController {
@IgnoreLog(IgnoreLogMode.RET) @IgnoreLog(IgnoreLogMode.RET)
@PutMapping("/offline-session") @PutMapping("/offline-session")
@Operation(summary = "下线当前用户会话") @Operation(summary = "下线当前用户会话")
public HttpWrapper<?> offlineCurrentUserSession(@Validated @RequestBody OfflineUserSessionRequest request) { public HttpWrapper<?> offlineCurrentUserSession(@Validated @RequestBody UserSessionOfflineRequest request) {
mineService.offlineCurrentUserSession(request); mineService.offlineCurrentUserSession(request);
return HttpWrapper.ok(); return HttpWrapper.ok();
} }
// fixme 全部用户接口进行 设置缓存
// fixme 操作日志 // fixme 操作日志
} }

View File

@@ -99,6 +99,7 @@ public class SystemRoleController {
@GetMapping("/get-menu-id") @GetMapping("/get-menu-id")
@Operation(summary = "获取角色菜单id") @Operation(summary = "获取角色菜单id")
@Parameter(name = "roleId", description = "roleId", required = true)
@PreAuthorize("@ss.hasPermission('infra:system-role:query')") @PreAuthorize("@ss.hasPermission('infra:system-role:query')")
public List<Long> getRoleMenuIdList(@RequestParam("roleId") Long roleId) { public List<Long> getRoleMenuIdList(@RequestParam("roleId") Long roleId) {
return systemRoleMenuService.getRoleMenuIdList(roleId); return systemRoleMenuService.getRoleMenuIdList(roleId);

View File

@@ -110,7 +110,7 @@ public class SystemUserController {
@Operation(summary = "查询所有用户") @Operation(summary = "查询所有用户")
@PreAuthorize("@ss.hasPermission('infra:system-user:query')") @PreAuthorize("@ss.hasPermission('infra:system-user:query')")
public List<SystemUserVO> getSystemUserList() { public List<SystemUserVO> getSystemUserList() {
return systemUserService.getSystemUserByIdList(); return systemUserService.getSystemUserList();
} }
@IgnoreLog(IgnoreLogMode.RET) @IgnoreLog(IgnoreLogMode.RET)
@@ -150,7 +150,7 @@ public class SystemUserController {
@IgnoreLog(IgnoreLogMode.RET) @IgnoreLog(IgnoreLogMode.RET)
@PutMapping("/offline-session") @PutMapping("/offline-session")
@Operation(summary = "下线用户会话") @Operation(summary = "下线用户会话")
public HttpWrapper<?> offlineUserSession(@Validated @RequestBody OfflineUserSessionRequest request) { public HttpWrapper<?> offlineUserSession(@Validated @RequestBody UserSessionOfflineRequest request) {
systemUserService.offlineUserSession(request); systemUserService.offlineUserSession(request);
return HttpWrapper.ok(); return HttpWrapper.ok();
} }

View File

@@ -2,6 +2,7 @@ package com.orion.ops.module.infra.convert;
import com.orion.ops.framework.common.security.LoginUser; import com.orion.ops.framework.common.security.LoginUser;
import com.orion.ops.module.infra.entity.domain.SystemUserDO; import com.orion.ops.module.infra.entity.domain.SystemUserDO;
import com.orion.ops.module.infra.entity.dto.UserInfoDTO;
import com.orion.ops.module.infra.entity.request.user.SystemUserCreateRequest; import com.orion.ops.module.infra.entity.request.user.SystemUserCreateRequest;
import com.orion.ops.module.infra.entity.request.user.SystemUserQueryRequest; import com.orion.ops.module.infra.entity.request.user.SystemUserQueryRequest;
import com.orion.ops.module.infra.entity.request.user.SystemUserUpdateRequest; import com.orion.ops.module.infra.entity.request.user.SystemUserUpdateRequest;
@@ -35,10 +36,14 @@ public interface SystemUserConvert {
SystemUserVO to(SystemUserDO domain); SystemUserVO to(SystemUserDO domain);
SystemUserVO to(UserInfoDTO user);
List<SystemUserVO> to(List<SystemUserDO> list); List<SystemUserVO> to(List<SystemUserDO> list);
LoginUser toLoginUser(SystemUserDO domain); LoginUser toLoginUser(SystemUserDO domain);
UserInfoDTO toUserInfo(SystemUserDO domain);
UserCollectInfoVO toCollectInfo(LoginUser user); UserCollectInfoVO toCollectInfo(LoginUser user);
} }

View File

@@ -4,6 +4,7 @@ import com.orion.lang.define.cache.CacheKeyBuilder;
import com.orion.lang.define.cache.CacheKeyDefine; import com.orion.lang.define.cache.CacheKeyDefine;
import com.orion.ops.framework.common.security.LoginUser; import com.orion.ops.framework.common.security.LoginUser;
import com.orion.ops.module.infra.entity.dto.LoginTokenDTO; import com.orion.ops.module.infra.entity.dto.LoginTokenDTO;
import com.orion.ops.module.infra.entity.dto.UserInfoDTO;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -22,9 +23,17 @@ public interface UserCacheKeyDefine {
.type(LoginUser.class) .type(LoginUser.class)
.build(); .build();
CacheKeyDefine USER_LIST = new CacheKeyBuilder()
.key("user:list:{}")
.desc("用户列表")
.type(UserInfoDTO.class)
.timeout(1, TimeUnit.DAYS)
.build();
CacheKeyDefine LOGIN_FAILED_COUNT = new CacheKeyBuilder() CacheKeyDefine LOGIN_FAILED_COUNT = new CacheKeyBuilder()
.key("user:failed:{}") .key("user:failed:{}")
.desc("用户登录失败次数 ${username}") .desc("用户登录失败次数 ${username}")
.type(Integer.class)
.timeout(3, TimeUnit.DAYS) .timeout(3, TimeUnit.DAYS)
.build(); .build();

View File

@@ -0,0 +1,48 @@
package com.orion.ops.module.infra.entity.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 用户信息 缓存对象
*
* @author Jiahang Li
* @version 1.0.0
* @since 2023-7-13 18:42
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(name = "UserInfoDTO", description = "用户信息 缓存对象")
public class UserInfoDTO implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "id")
private Long id;
@Schema(description = "用户名")
private String username;
@Schema(description = "花名")
private String nickname;
@Schema(description = "头像地址")
private String avatar;
@Schema(description = "手机号")
private String mobile;
@Schema(description = "邮箱")
private String email;
@Schema(description = "用户状态 0停用 1启用 2锁定")
private Integer status;
}

View File

@@ -6,15 +6,15 @@ import lombok.Data;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
/** /**
* 用户下线请求 * 用户会话下线请求
* *
* @author Jiahang Li * @author Jiahang Li
* @version 1.0.0 * @version 1.0.0
* @since 2023/7/17 12:19 * @since 2023/7/17 12:19
*/ */
@Data @Data
@Schema(name = "OfflineUserSessionRequest", description = "用户下线请求") @Schema(name = "UserSessionOfflineRequest", description = "用户会话下线请求")
public class OfflineUserSessionRequest { public class UserSessionOfflineRequest {
@Schema(description = "userId") @Schema(description = "userId")
private Long userId; private Long userId;

View File

@@ -1,6 +1,6 @@
package com.orion.ops.module.infra.service; package com.orion.ops.module.infra.service;
import com.orion.ops.module.infra.entity.request.user.OfflineUserSessionRequest; import com.orion.ops.module.infra.entity.request.user.UserSessionOfflineRequest;
import com.orion.ops.module.infra.entity.request.user.SystemUserUpdateRequest; import com.orion.ops.module.infra.entity.request.user.SystemUserUpdateRequest;
import com.orion.ops.module.infra.entity.request.user.UserUpdatePasswordRequest; import com.orion.ops.module.infra.entity.request.user.UserUpdatePasswordRequest;
import com.orion.ops.module.infra.entity.vo.LoginHistoryVO; import com.orion.ops.module.infra.entity.vo.LoginHistoryVO;
@@ -59,6 +59,6 @@ public interface MineService {
* *
* @param request request * @param request request
*/ */
void offlineCurrentUserSession(OfflineUserSessionRequest request); void offlineCurrentUserSession(UserSessionOfflineRequest request);
} }

View File

@@ -53,7 +53,7 @@ public interface SystemUserService {
* *
* @return rows * @return rows
*/ */
List<SystemUserVO> getSystemUserByIdList(); List<SystemUserVO> getSystemUserList();
/** /**
* 分页查询用户 * 分页查询用户
@@ -74,9 +74,10 @@ public interface SystemUserService {
/** /**
* 删除 id 删除用户拓展信息 * 删除 id 删除用户拓展信息
* *
* @param id id * @param id id
* @param username username
*/ */
void deleteSystemUserRel(Long id); void deleteSystemUserRel(Long id, String username);
/** /**
* 重置密码 * 重置密码
@@ -98,6 +99,6 @@ public interface SystemUserService {
* *
* @param request request * @param request request
*/ */
void offlineUserSession(OfflineUserSessionRequest request); void offlineUserSession(UserSessionOfflineRequest request);
} }

View File

@@ -6,7 +6,7 @@ import com.orion.ops.framework.common.utils.Valid;
import com.orion.ops.framework.security.core.utils.SecurityUtils; import com.orion.ops.framework.security.core.utils.SecurityUtils;
import com.orion.ops.module.infra.dao.SystemUserDAO; import com.orion.ops.module.infra.dao.SystemUserDAO;
import com.orion.ops.module.infra.entity.domain.SystemUserDO; import com.orion.ops.module.infra.entity.domain.SystemUserDO;
import com.orion.ops.module.infra.entity.request.user.OfflineUserSessionRequest; import com.orion.ops.module.infra.entity.request.user.UserSessionOfflineRequest;
import com.orion.ops.module.infra.entity.request.user.SystemUserUpdateRequest; import com.orion.ops.module.infra.entity.request.user.SystemUserUpdateRequest;
import com.orion.ops.module.infra.entity.request.user.UserResetPasswordRequest; import com.orion.ops.module.infra.entity.request.user.UserResetPasswordRequest;
import com.orion.ops.module.infra.entity.request.user.UserUpdatePasswordRequest; import com.orion.ops.module.infra.entity.request.user.UserUpdatePasswordRequest;
@@ -81,7 +81,7 @@ public class MineServiceImpl implements MineService {
} }
@Override @Override
public void offlineCurrentUserSession(OfflineUserSessionRequest request) { public void offlineCurrentUserSession(UserSessionOfflineRequest request) {
request.setUserId(SecurityUtils.getLoginUserId()); request.setUserId(SecurityUtils.getLoginUserId());
systemUserService.offlineUserSession(request); systemUserService.offlineUserSession(request);
} }

View File

@@ -6,10 +6,12 @@ import com.orion.lang.define.wrapper.DataGrid;
import com.orion.lang.utils.collect.Lists; import com.orion.lang.utils.collect.Lists;
import com.orion.lang.utils.crypto.Signatures; import com.orion.lang.utils.crypto.Signatures;
import com.orion.ops.framework.biz.operator.log.core.uitls.OperatorLogs; import com.orion.ops.framework.biz.operator.log.core.uitls.OperatorLogs;
import com.orion.ops.framework.common.constant.Const;
import com.orion.ops.framework.common.constant.ErrorCode; import com.orion.ops.framework.common.constant.ErrorCode;
import com.orion.ops.framework.common.constant.ErrorMessage; import com.orion.ops.framework.common.constant.ErrorMessage;
import com.orion.ops.framework.common.security.LoginUser; import com.orion.ops.framework.common.security.LoginUser;
import com.orion.ops.framework.common.utils.Valid; import com.orion.ops.framework.common.utils.Valid;
import com.orion.ops.framework.redis.core.utils.RedisMaps;
import com.orion.ops.framework.redis.core.utils.RedisStrings; import com.orion.ops.framework.redis.core.utils.RedisStrings;
import com.orion.ops.framework.redis.core.utils.RedisUtils; import com.orion.ops.framework.redis.core.utils.RedisUtils;
import com.orion.ops.framework.security.core.utils.SecurityUtils; import com.orion.ops.framework.security.core.utils.SecurityUtils;
@@ -21,9 +23,11 @@ import com.orion.ops.module.infra.define.cache.TipsCacheKeyDefine;
import com.orion.ops.module.infra.define.cache.UserCacheKeyDefine; import com.orion.ops.module.infra.define.cache.UserCacheKeyDefine;
import com.orion.ops.module.infra.entity.domain.SystemUserDO; import com.orion.ops.module.infra.entity.domain.SystemUserDO;
import com.orion.ops.module.infra.entity.dto.LoginTokenDTO; import com.orion.ops.module.infra.entity.dto.LoginTokenDTO;
import com.orion.ops.module.infra.entity.dto.UserInfoDTO;
import com.orion.ops.module.infra.entity.request.user.*; import com.orion.ops.module.infra.entity.request.user.*;
import com.orion.ops.module.infra.entity.vo.SystemUserVO; import com.orion.ops.module.infra.entity.vo.SystemUserVO;
import com.orion.ops.module.infra.entity.vo.UserSessionVO; import com.orion.ops.module.infra.entity.vo.UserSessionVO;
import com.orion.ops.module.infra.enums.LoginTokenStatusEnum;
import com.orion.ops.module.infra.enums.UserStatusEnum; import com.orion.ops.module.infra.enums.UserStatusEnum;
import com.orion.ops.module.infra.service.AuthenticationService; import com.orion.ops.module.infra.service.AuthenticationService;
import com.orion.ops.module.infra.service.FavoriteService; import com.orion.ops.module.infra.service.FavoriteService;
@@ -31,7 +35,6 @@ import com.orion.ops.module.infra.service.PreferenceService;
import com.orion.ops.module.infra.service.SystemUserService; import com.orion.ops.module.infra.service.SystemUserService;
import com.orion.spring.SpringHolder; import com.orion.spring.SpringHolder;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -68,9 +71,6 @@ public class SystemUserServiceImpl implements SystemUserService {
@Resource @Resource
private PreferenceService preferenceService; private PreferenceService preferenceService;
@Resource
private RedisTemplate<String, String> redisTemplate;
@Override @Override
public Long createSystemUser(SystemUserCreateRequest request) { public Long createSystemUser(SystemUserCreateRequest request) {
// 转换 // 转换
@@ -82,6 +82,8 @@ public class SystemUserServiceImpl implements SystemUserService {
// 插入 // 插入
int effect = systemUserDAO.insert(record); int effect = systemUserDAO.insert(record);
log.info("SystemUserService-createSystemUser effect: {}, record: {}", effect, JSON.toJSONString(record)); log.info("SystemUserService-createSystemUser effect: {}, record: {}", effect, JSON.toJSONString(record));
// 删除用户列表缓存
RedisUtils.delete(UserCacheKeyDefine.USER_LIST);
return record.getId(); return record.getId();
} }
@@ -104,6 +106,8 @@ public class SystemUserServiceImpl implements SystemUserService {
RedisStrings.<LoginUser>processSetJson(UserCacheKeyDefine.USER_INFO, s -> { RedisStrings.<LoginUser>processSetJson(UserCacheKeyDefine.USER_INFO, s -> {
s.setNickname(request.getNickname()); s.setNickname(request.getNickname());
}, id); }, id);
// 删除用户列表缓存
RedisUtils.delete(UserCacheKeyDefine.USER_LIST);
return effect; return effect;
} }
@@ -131,12 +135,14 @@ public class SystemUserServiceImpl implements SystemUserService {
log.info("SystemUserService-updateUserStatus effect: {}, updateRecord: {}", effect, JSON.toJSONString(updateRecord)); log.info("SystemUserService-updateUserStatus effect: {}, updateRecord: {}", effect, JSON.toJSONString(updateRecord));
// 如果之前是锁定则删除登录失败次数缓存 // 如果之前是锁定则删除登录失败次数缓存
if (UserStatusEnum.LOCKED.getStatus().equals(record.getStatus())) { if (UserStatusEnum.LOCKED.getStatus().equals(record.getStatus())) {
redisTemplate.delete(UserCacheKeyDefine.LOGIN_FAILED_COUNT.format(record.getUsername())); RedisUtils.delete(UserCacheKeyDefine.LOGIN_FAILED_COUNT.format(record.getUsername()));
} }
// 更新缓存中的status // 更新用户缓存中的 status
RedisStrings.<LoginUser>processSetJson(UserCacheKeyDefine.USER_INFO, s -> { RedisStrings.<LoginUser>processSetJson(UserCacheKeyDefine.USER_INFO, s -> {
s.setStatus(request.getStatus()); s.setStatus(request.getStatus());
}, id); }, id);
// 删除用户列表缓存
RedisUtils.delete(UserCacheKeyDefine.USER_LIST);
return effect; return effect;
} }
@@ -150,14 +156,28 @@ public class SystemUserServiceImpl implements SystemUserService {
} }
@Override @Override
public List<SystemUserVO> getSystemUserByIdList() { public List<SystemUserVO> getSystemUserList() {
// 查询 // fixme test
List<SystemUserDO> records = systemUserDAO.selectList(null); // 查询用户列表
if (records.isEmpty()) { List<UserInfoDTO> list = RedisMaps.valuesJson(UserCacheKeyDefine.USER_LIST);
return Lists.empty(); if (list.isEmpty()) {
// 查询数据库
list = systemUserDAO.of().list(SystemUserConvert.MAPPER::toUserInfo);
// 添加默认值 防止穿透
if (list.isEmpty()) {
list.add(UserInfoDTO.builder()
.id(Const.NONE_ID)
.build());
}
// 设置缓存
RedisMaps.putAllJson(UserCacheKeyDefine.USER_LIST.getKey(), s -> s.getId().toString(), list);
RedisMaps.setExpire(UserCacheKeyDefine.USER_LIST);
} }
// 转换 // 删除默认值
return SystemUserConvert.MAPPER.to(records); return list.stream()
.filter(s -> !s.getId().equals(Const.NONE_ID))
.map(SystemUserConvert.MAPPER::to)
.collect(Collectors.toList());
} }
@Override @Override
@@ -190,21 +210,26 @@ public class SystemUserServiceImpl implements SystemUserService {
int effect = systemUserDAO.deleteById(id); int effect = systemUserDAO.deleteById(id);
log.info("SystemUserService-deleteSystemUserById id: {}, effect: {}", id, effect); log.info("SystemUserService-deleteSystemUserById id: {}, effect: {}", id, effect);
// 异步删除额外信息 // 异步删除额外信息
SpringHolder.getBean(SystemUserService.class).deleteSystemUserRel(id); SpringHolder.getBean(SystemUserService.class).deleteSystemUserRel(id, record.getUsername());
return effect; return effect;
} }
@Override @Override
@Async("asyncExecutor") @Async("asyncExecutor")
public void deleteSystemUserRel(Long id) { public void deleteSystemUserRel(Long id, String username) {
log.info("SystemUserService-deleteSystemUserRel id: {}", id); log.info("SystemUserService-deleteSystemUserRel id: {}", id);
// 删除用户列表缓存
// FIXME test
RedisMaps.delete(UserCacheKeyDefine.USER_LIST, id);
// 删除用户缓存 需要扫描的 key 让其自动过期 // 删除用户缓存 需要扫描的 key 让其自动过期
redisTemplate.delete(Lists.of( RedisUtils.delete(
// 用户缓存 // 用户缓存
UserCacheKeyDefine.USER_INFO.format(id), UserCacheKeyDefine.USER_INFO.format(id),
// 登录失败次数
UserCacheKeyDefine.LOGIN_FAILED_COUNT.format(username),
// 用户提示 // 用户提示
TipsCacheKeyDefine.TIPS.format(id) TipsCacheKeyDefine.TIPS.format(id)
)); );
// 删除角色关联 // 删除角色关联
systemUserRoleDAO.deleteByUserId(id); systemUserRoleDAO.deleteByUserId(id);
// 删除操作日志 // 删除操作日志
@@ -230,19 +255,19 @@ public class SystemUserServiceImpl implements SystemUserService {
int effect = systemUserDAO.updateById(update); int effect = systemUserDAO.updateById(update);
log.info("SystemUserService-resetPassword record: {}, effect: {}", JSON.toJSONString(update), effect); log.info("SystemUserService-resetPassword record: {}, effect: {}", JSON.toJSONString(update), effect);
// 删除登录失败次数缓存 // 删除登录失败次数缓存
redisTemplate.delete(UserCacheKeyDefine.LOGIN_FAILED_COUNT.format(record.getUsername())); RedisUtils.delete(UserCacheKeyDefine.LOGIN_FAILED_COUNT.format(record.getUsername()));
// 删除登录缓存 // 删除登录缓存
String loginKey = UserCacheKeyDefine.LOGIN_TOKEN.format(id, "*"); String loginKey = UserCacheKeyDefine.LOGIN_TOKEN.format(id, "*");
Set<String> loginKeyList = RedisUtils.scanKeys(loginKey); Set<String> loginKeyList = RedisUtils.scanKeys(loginKey);
if (!loginKeyList.isEmpty()) { if (!loginKeyList.isEmpty()) {
redisTemplate.delete(loginKeyList); RedisUtils.delete(loginKeyList);
} }
// 删除续签信息 // 删除续签信息
if (AuthenticationService.allowRefresh) { if (AuthenticationService.allowRefresh) {
String refreshKey = UserCacheKeyDefine.LOGIN_REFRESH.format(id, "*"); String refreshKey = UserCacheKeyDefine.LOGIN_REFRESH.format(id, "*");
Set<String> refreshKeyList = RedisUtils.scanKeys(refreshKey); Set<String> refreshKeyList = RedisUtils.scanKeys(refreshKey);
if (!refreshKeyList.isEmpty()) { if (!refreshKeyList.isEmpty()) {
redisTemplate.delete(refreshKeyList); RedisUtils.delete(refreshKeyList);
} }
} }
} }
@@ -259,22 +284,25 @@ public class SystemUserServiceImpl implements SystemUserService {
if (Lists.isEmpty(tokens)) { if (Lists.isEmpty(tokens)) {
return Lists.empty(); return Lists.empty();
} }
final boolean isCurrentUser = userId.equals(SecurityUtils.getLoginUserId());
// 返回 // 返回
return tokens.stream() return tokens.stream()
.filter(s -> LoginTokenStatusEnum.OK.getStatus().equals(s.getStatus()))
.map(LoginTokenDTO::getOrigin) .map(LoginTokenDTO::getOrigin)
.map(s -> UserSessionVO.builder() .map(s -> UserSessionVO.builder()
.current(s.getLoginTime().equals(SecurityUtils.getLoginTimestamp())) .current(isCurrentUser && s.getLoginTime().equals(SecurityUtils.getLoginTimestamp()))
.address(s.getAddress()) .address(s.getAddress())
.location(s.getLocation()) .location(s.getLocation())
.userAgent(s.getUserAgent()) .userAgent(s.getUserAgent())
.loginTime(new Date(s.getLoginTime())) .loginTime(new Date(s.getLoginTime()))
.build()) .build())
.sorted(Comparator.comparing(UserSessionVO::getLoginTime).reversed()) .sorted(Comparator.comparing(UserSessionVO::getCurrent).reversed()
.thenComparing(Comparator.comparing(UserSessionVO::getLoginTime).reversed()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override @Override
public void offlineUserSession(OfflineUserSessionRequest request) { public void offlineUserSession(UserSessionOfflineRequest request) {
Long userId = Valid.notNull(request.getUserId()); Long userId = Valid.notNull(request.getUserId());
Long timestamp = request.getTimestamp(); Long timestamp = request.getTimestamp();
RedisStrings.delete( RedisStrings.delete(

View File

@@ -35,11 +35,11 @@ export interface HostIdentityQueryRequest extends Pagination {
* 主机身份查询响应 * 主机身份查询响应
*/ */
export interface HostIdentityQueryResponse extends TableData { export interface HostIdentityQueryResponse extends TableData {
id?: number; id: number;
name?: string; name: string;
username?: string; username: string;
password?: string; password: string;
keyId?: number; keyId: number;
createTime: number; createTime: number;
updateTime: number; updateTime: number;
creator: string; creator: string;

View File

@@ -35,11 +35,11 @@ export interface HostKeyQueryRequest extends Pagination {
* 主机秘钥查询响应 * 主机秘钥查询响应
*/ */
export interface HostKeyQueryResponse extends TableData { export interface HostKeyQueryResponse extends TableData {
id?: number; id: number;
name?: string; name: string;
publicKey?: string; publicKey: string;
privateKey?: string; privateKey: string;
password?: string; password: string;
createTime: number; createTime: number;
updateTime: number; updateTime: number;
} }

View File

@@ -38,10 +38,10 @@ export interface HostQueryRequest extends Pagination {
* 主机查询响应 * 主机查询响应
*/ */
export interface HostQueryResponse extends TableData { export interface HostQueryResponse extends TableData {
id?: number; id: number;
name?: string; name: string;
code?: string; code: string;
address?: string; address: string;
createTime: number; createTime: number;
updateTime: number; updateTime: number;
creator: string; creator: string;

View File

@@ -15,9 +15,9 @@ export interface HistoryValueQueryRequest extends Pagination {
* 历史归档查询响应 * 历史归档查询响应
*/ */
export interface HistoryValueQueryResponse extends TableData { export interface HistoryValueQueryResponse extends TableData {
id?: number; id: number;
beforeValue?: string; beforeValue: string;
afterValue?: string; afterValue: string;
createTime: number; createTime: number;
creator: string; creator: string;
} }

View File

@@ -34,11 +34,11 @@ export interface DictKeyQueryRequest extends Pagination {
* 字典配置项查询响应 * 字典配置项查询响应
*/ */
export interface DictKeyQueryResponse extends TableData { export interface DictKeyQueryResponse extends TableData {
id?: number; id: number;
keyName?: string; keyName: string;
valueType?: string; valueType: string;
extraSchema?: string; extraSchema: string;
description?: string; description: string;
} }
/** /**

View File

@@ -48,14 +48,14 @@ export interface DictValueQueryRequest extends Pagination {
* 字典配置值查询响应 * 字典配置值查询响应
*/ */
export interface DictValueQueryResponse extends TableData { export interface DictValueQueryResponse extends TableData {
id?: number; id: number;
keyId?: number; keyId: number;
keyName?: string; keyName: string;
keyDescription?: string; keyDescription: string;
value?: string; value: string;
label?: string; label: string;
extra?: string; extra: string;
sort?: number; sort: number;
createTime: number; createTime: number;
updateTime: number; updateTime: number;
creator: string; creator: string;

View File

@@ -37,25 +37,25 @@ export interface MenuQueryRequest {
* 菜单查询响应 * 菜单查询响应
*/ */
export interface MenuQueryResponse extends TableData { export interface MenuQueryResponse extends TableData {
id?: number; id: number;
parentId?: number; parentId: number;
name?: string; name: string;
permission?: string; permission: string;
type?: number; type: number;
sort?: number; sort: number;
visible?: number; visible: number;
status?: number; status: number;
cache?: number; cache: number;
icon?: string; icon: string;
path?: string; path: string;
component?: string; component: string;
children?: Array<MenuQueryResponse>; children: Array<MenuQueryResponse>;
} }
/** /**
* 查询菜单列表 * 查询菜单列表
*/ */
export function getMenuList(request?: MenuQueryRequest) { export function getMenuList(request: MenuQueryRequest) {
return axios.post<MenuQueryResponse[]>('/infra/system-menu/list', request); return axios.post<MenuQueryResponse[]>('/infra/system-menu/list', request);
} }

View File

@@ -1,5 +1,5 @@
import type { LoginHistoryQueryResponse } from './operator-log'; import type { LoginHistoryQueryResponse } from './operator-log';
import type { UserQueryResponse, UserUpdateRequest } from './user'; import type { UserQueryResponse, UserSessionQueryResponse, UserSessionOfflineRequest, UserUpdateRequest } from './user';
import axios from 'axios'; import axios from 'axios';
/** /**
@@ -38,3 +38,16 @@ export function getCurrentLoginHistory() {
return axios.get<LoginHistoryQueryResponse[]>('/infra/mine/login-history'); return axios.get<LoginHistoryQueryResponse[]>('/infra/mine/login-history');
} }
/**
* 获取当前用户会话列表
*/
export function getCurrentUserSessionList() {
return axios.get<UserSessionQueryResponse[]>('/infra/mine/user-session');
}
/**
* 下线当前用户会话
*/
export function offlineCurrentUserSession(request: UserSessionOfflineRequest) {
return axios.put('/infra/mine/offline-session', request);
}

View File

@@ -30,6 +30,7 @@ export interface OperatorLogQueryResponse {
module: string; module: string;
type: string; type: string;
logInfo: string; logInfo: string;
originLogInfo: string;
extra: string; extra: string;
result: number; result: number;
errorMessage: string; errorMessage: string;

View File

@@ -40,10 +40,10 @@ export interface RoleQueryRequest extends Pagination {
* 角色查询响应 * 角色查询响应
*/ */
export interface RoleQueryResponse extends TableData { export interface RoleQueryResponse extends TableData {
id?: number; id: number;
name?: string; name: string;
code?: string; code: string;
status?: number; status: number;
createTime: number; createTime: number;
updateTime: number; updateTime: number;
creator: string; creator: string;

View File

@@ -42,13 +42,13 @@ export interface UserQueryRequest extends Pagination {
* 用户查询响应 * 用户查询响应
*/ */
export interface UserQueryResponse extends TableData { export interface UserQueryResponse extends TableData {
id?: number; id: number;
username?: string; username: string;
nickname?: string; nickname: string;
avatar?: string; avatar: string;
mobile?: string; mobile: string;
email?: string; email: string;
status?: number; status: number;
lastLoginTime?: number; lastLoginTime?: number;
createTime: number; createTime: number;
updateTime: number; updateTime: number;
@@ -56,6 +56,26 @@ export interface UserQueryResponse extends TableData {
updater: string; updater: string;
} }
/**
* 用户会话查询响应
*/
export interface UserSessionQueryResponse {
visible: boolean;
current: boolean;
address: string;
location: string;
userAgent: string;
loginTime: number;
}
/**
* 用户会话下线请求
*/
export interface UserSessionOfflineRequest {
userId?: number;
timestamp: number;
}
/** /**
* 创建用户 * 创建用户
*/ */

View File

@@ -122,6 +122,18 @@
cursor: pointer; cursor: pointer;
} }
.mx0 {
margin: 0 0;
}
.mx2 {
margin: 0 2px;
}
.mx4 {
margin: 0 4px;
}
.ml4 { .ml4 {
margin-left: 4px; margin-left: 4px;
} }

View File

@@ -0,0 +1,61 @@
<template>
<a-select v-model:model-value="value as any"
:options="optionData()"
:allow-search="true"
:multiple="multiple"
:loading="loading"
:disabled="loading"
:filter-option="filterOption"
placeholder="请选择用户" />
</template>
<script lang="ts">
export default {
name: 'user-selector'
};
</script>
<script lang="ts" setup>
import type { PropType } from 'vue';
import type { SelectOptionData } from '@arco-design/web-vue';
import { computed } from 'vue';
import { useCacheStore } from '@/store';
import { RoleStatus } from '@/views/user/role/types/const';
const props = defineProps({
modelValue: [Number, Array] as PropType<number | Array<number>>,
loading: Boolean,
multiple: Boolean,
});
const emits = defineEmits(['update:modelValue']);
const value = computed({
get() {
return props.modelValue;
},
set(e) {
emits('update:modelValue', e);
}
});
// 选项数据
const cacheStore = useCacheStore();
const optionData = (): SelectOptionData[] => {
return cacheStore.users.map(s => {
return {
label: `${s.nickname} (${s.username})`,
value: s.id,
};
});
};
// 搜索
const filterOption = (searchValue: string, option: { label: string; }) => {
return option.label.toLowerCase().indexOf(searchValue.toLowerCase()) > -1;
};
</script>
<style scoped>
</style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<a-modal v-model:visible="visible" <a-modal v-model:visible="visible"
title-align="start" title-align="start"
width="60%" :width="width"
:body-style="{padding: '16px 8px'}" :body-style="{padding: '16px 8px'}"
:top="80" :top="80"
:title="title" :title="title"
@@ -11,59 +11,57 @@
:unmount-on-close="true" :unmount-on-close="true"
:footer="false" :footer="false"
@close="handleClose"> @close="handleClose">
<a-spin :loading="loading" style="width: 100%; height: calc(100vh - 240px)"> <div :style="{width: '100%', 'height': height}">
<editor v-model="value" readonly /> <editor v-model="value" readonly />
</a-spin> </div>
</a-modal> </a-modal>
</template> </template>
<script lang="ts"> <script lang="ts">
export default { export default {
name: 'dict-key-view-modal' name: 'json-view-modal'
}; };
</script> </script>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import useLoading from '@/hooks/loading';
import useVisible from '@/hooks/visible'; import useVisible from '@/hooks/visible';
import { getDictValueList } from '@/api/system/dict-value'; import { isString } from '@/utils/is';
const props = defineProps({
width: {
type: [String, Number],
default: '60%'
},
height: {
type: String,
default: 'calc(100vh - 240px)'
}
});
const { visible, setVisible } = useVisible(); const { visible, setVisible } = useVisible();
const { loading, setLoading } = useLoading();
const title = ref<string>(); const title = ref<string>();
const value = ref<string>(); const value = ref<string | any>();
//
const open = (e: any) => {
title.value = e.keyName;
value.value = undefined;
render(e.keyName);
setVisible(true);
};
// //
const render = async (keyName: string) => { const open = (editorValue: string | any, editorTitle = 'json') => {
try { title.value = editorTitle;
setLoading(true); if (isString(editorValue)) {
// value.value = editorValue;
const { data } = await getDictValueList([keyName]); } else {
value.value = JSON.stringify(data[keyName], undefined, 4); value.value = JSON.stringify(editorValue, undefined, 4);
} catch (e) {
} finally {
setLoading(false);
} }
setVisible(true);
}; };
defineExpose({ open }); defineExpose({ open });
// //
const handleClose = () => { const handleClose = () => {
setLoading(false);
setVisible(false); setVisible(false);
}; };
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@@ -21,6 +21,11 @@ const USER: AppRouteRecordRaw = {
path: '/user/info', path: '/user/info',
component: () => import('@/views/user/info/index.vue'), component: () => import('@/views/user/info/index.vue'),
}, },
{
name: 'userOperatorLog',
path: '/user/operator-log',
component: () => import('@/views/user/operator-log/index.vue'),
},
], ],
}; };

View File

@@ -1,10 +1,11 @@
import type { CacheState } from './types'; import type { CacheState } from './types';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
export type CacheType = 'menus' | 'roles' | 'hostTags' | 'hostKeys' | 'hostIdentities' | 'dictKeys' | string export type CacheType = 'users' | 'menus' | 'roles' | 'hostTags' | 'hostKeys' | 'hostIdentities' | 'dictKeys' | string
export default defineStore('cache', { export default defineStore('cache', {
state: (): CacheState => ({ state: (): CacheState => ({
users: [],
menus: [], menus: [],
roles: [], roles: [],
hostTags: [], hostTags: [],

View File

@@ -1,3 +1,4 @@
import type { UserQueryResponse } from '@/api/user/user';
import type { MenuQueryResponse } from '@/api/system/menu'; import type { MenuQueryResponse } from '@/api/system/menu';
import type { RoleQueryResponse } from '@/api/user/role'; import type { RoleQueryResponse } from '@/api/user/role';
import type { TagQueryResponse } from '@/api/meta/tag'; import type { TagQueryResponse } from '@/api/meta/tag';
@@ -6,6 +7,7 @@ import type { HostIdentityQueryResponse } from '@/api/asset/host-identity';
import type { DictKeyQueryResponse } from '@/api/system/dict-key'; import type { DictKeyQueryResponse } from '@/api/system/dict-key';
export interface CacheState { export interface CacheState {
users: UserQueryResponse[];
menus: MenuQueryResponse[]; menus: MenuQueryResponse[];
roles: RoleQueryResponse[]; roles: RoleQueryResponse[];
hostTags: TagQueryResponse[]; hostTags: TagQueryResponse[];

View File

@@ -15,11 +15,11 @@ export const openWindow = (
url, url,
target, target,
Object.entries(others) Object.entries(others)
.reduce((preValue: string[], curValue) => { .reduce((preValue: string[], curValue) => {
const [key, value] = curValue; const [key, value] = curValue;
return [...preValue, `${key}=${value}`]; return [...preValue, `${key}=${value}`];
}, []) }, [])
.join(',') .join(',')
); );
}; };
@@ -161,10 +161,10 @@ export function replaceNumber(value: string) {
*/ */
export const resetObject = (obj: any, ignore: string[] = []) => { export const resetObject = (obj: any, ignore: string[] = []) => {
Object.keys(obj) Object.keys(obj)
.filter(s => !ignore.includes(s)) .filter(s => !ignore.includes(s))
.forEach(k => { .forEach(k => {
obj[k] = undefined; obj[k] = undefined;
}); });
}; };
/** /**
@@ -172,11 +172,11 @@ export const resetObject = (obj: any, ignore: string[] = []) => {
*/ */
export const objectTruthKeyCount = (obj: any, ignore: string[] = []) => { export const objectTruthKeyCount = (obj: any, ignore: string[] = []) => {
return Object.keys(obj) return Object.keys(obj)
.filter(s => !ignore.includes(s)) .filter(s => !ignore.includes(s))
.reduce(function(acc, curr) { .reduce(function(acc, curr) {
const currVal = obj[curr]; const currVal = obj[curr];
return acc + ~~(currVal !== undefined && currVal !== null && currVal?.length !== 0 && currVal !== ''); return acc + ~~(currVal !== undefined && currVal !== null && currVal?.length !== 0 && currVal !== '');
}, 0); }, 0);
}; };
/** /**
@@ -213,4 +213,52 @@ export function getUUID() {
}); });
} }
/**
* 清除 xss
*/
export function cleanXss(s: string) {
return s.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('\'', '&apos;')
.replaceAll('"', '&quot;')
.replaceAll('\n', '<br/>')
.replaceAll('\t', '&nbsp;&nbsp;&nbsp;&nbsp;');
}
/**
* 替换 html 标签
*/
export function replaceHtmlTag(message: string) {
return cleanXss(message)
.replaceAll('&lt;sb 0&gt;', '<span class="span-blue mx0">')
.replaceAll('&lt;sb 2&gt;', '<span class="span-blue mx2">')
.replaceAll('&lt;sb&gt;', '<span class="span-blue mx4">')
.replaceAll('&lt;/sb&gt;', '</span>')
.replaceAll('&lt;sr 0&gt;', '<span class="span-red mx0">')
.replaceAll('&lt;sr 2&gt;', '<span class="span-red mx2">')
.replaceAll('&lt;sr&gt;', '<span class="span-red mx4">')
.replaceAll('&lt;/sr&gt;', '</span>')
.replaceAll('&lt;b&gt;', '<b>')
.replaceAll('&lt;/b&gt;', '</b>');
}
/**
* 清除 html 标签
*/
export function clearHtmlTag(message: string) {
return cleanXss(message)
.replaceAll('&lt;sb 0&gt;', '')
.replaceAll('&lt;sb 2&gt;', '')
.replaceAll('&lt;sb&gt;', '')
.replaceAll('&lt;/sb&gt;', '')
.replaceAll('&lt;sr 0&gt;', '')
.replaceAll('&lt;sr 2&gt;', '')
.replaceAll('&lt;sr&gt;', '')
.replaceAll('&lt;/sr&gt;', '')
.replaceAll('&lt;b&gt;', '')
.replaceAll('&lt;/b&gt;', '')
.replaceAll('<br/>', '\n');
}
export default null; export default null;

View File

@@ -51,3 +51,10 @@ export function isExist(obj: any): boolean {
export function isWindow(el: any): el is Window { export function isWindow(el: any): el is Window {
return el === window; return el === window;
} }
/**
* 是否为移动端 ua
*/
export function isMobile(userAgent: string) {
return /Mobi|Android|iPhone/i.test(userAgent);
}

View File

@@ -100,7 +100,7 @@
<!-- 查看 --> <!-- 查看 -->
<a-button type="text" <a-button type="text"
size="mini" size="mini"
@click="emits('openView', record)"> @click="openView(record)">
查看 查看
</a-button> </a-button>
<!-- 修改 --> <!-- 修改 -->
@@ -145,6 +145,7 @@
import { dictValueTypeKey } from '../types/const'; import { dictValueTypeKey } from '../types/const';
import useCopy from '@/hooks/copy'; import useCopy from '@/hooks/copy';
import { useDictStore } from '@/store'; import { useDictStore } from '@/store';
import { getDictValueList } from '@/api/system/dict-value';
const tableRenderData = ref<DictKeyQueryResponse[]>([]); const tableRenderData = ref<DictKeyQueryResponse[]>([]);
const emits = defineEmits(['openAdd', 'openUpdate', 'openView']); const emits = defineEmits(['openAdd', 'openUpdate', 'openView']);
@@ -191,6 +192,19 @@
addedCallback, updatedCallback addedCallback, updatedCallback
}); });
// 打开查看视图
const openView = async (record: DictKeyQueryResponse) => {
try {
setLoading(true);
// 查看
const { data } = await getDictValueList([record.keyName]);
emits('openView', data[record.keyName], `${record.keyName} - ${record.description}`);
} catch (e) {
} finally {
setLoading(false);
}
};
// 刷新缓存 // 刷新缓存
const doRefreshCache = async () => { const doRefreshCache = async () => {
try { try {

View File

@@ -4,13 +4,13 @@
<dict-key-table ref="table" <dict-key-table ref="table"
@openAdd="() => modal.openAdd()" @openAdd="() => modal.openAdd()"
@openUpdate="(e) => modal.openUpdate(e)" @openUpdate="(e) => modal.openUpdate(e)"
@openView="(e) => view.open(e)" /> @openView="(v, t) => view.open(v, t)" />
<!-- 添加修改模态框 --> <!-- 添加修改模态框 -->
<dict-key-form-modal ref="modal" <dict-key-form-modal ref="modal"
@added="modalAddCallback" @added="modalAddCallback"
@updated="modalUpdateCallback" /> @updated="modalUpdateCallback" />
<!-- json 查看器模态框 --> <!-- json 查看器模态框 -->
<dict-key-view-modal ref="view" /> <json-view-modal ref="view" />
</div> </div>
</template> </template>
@@ -24,7 +24,7 @@
import { ref, onBeforeMount } from 'vue'; import { ref, onBeforeMount } from 'vue';
import DictKeyTable from './components/dict-key-table.vue'; import DictKeyTable from './components/dict-key-table.vue';
import DictKeyFormModal from './components/dict-key-form-modal.vue'; import DictKeyFormModal from './components/dict-key-form-modal.vue';
import DictKeyViewModal from './components/dict-key-view-modal.vue'; import JsonViewModal from '@/components/view/json/json-view-modal.vue';
import { useDictStore } from '@/store'; import { useDictStore } from '@/store';
import { dictKeys } from './types/const'; import { dictKeys } from './types/const';

View File

@@ -7,16 +7,17 @@
<!-- 图标 --> <!-- 图标 -->
<template #dot> <template #dot>
<div class="icon-container"> <div class="icon-container">
<icon-desktop /> <icon-mobile v-if="isMobile(item.userAgent)" />
<icon-desktop v-else />
</div> </div>
</template> </template>
<!-- 日志行 --> <!-- 日志行 -->
<div class="log-line"> <div class="log-line">
<!-- 地址行 --> <!-- 地址行 -->
<span class="address-line"> <a-space class="address-line">
<span class="mr8">{{ item.address }}</span> <span class="mr8">{{ item.address }}</span>
<span>{{ item.location }}</span> <span>{{ item.location }}</span>
</span> </a-space>
<!-- 错误信息行 --> <!-- 错误信息行 -->
<span class="error-line" v-if="item.result === ResultStatus.FAILED"> <span class="error-line" v-if="item.result === ResultStatus.FAILED">
登录失败: {{ item.errorMessage }} 登录失败: {{ item.errorMessage }}
@@ -45,14 +46,13 @@
import type { LoginHistoryQueryResponse } from '@/api/user/operator-log'; import type { LoginHistoryQueryResponse } from '@/api/user/operator-log';
import useLoading from '@/hooks/loading'; import useLoading from '@/hooks/loading';
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useUserStore } from '@/store';
import { ResultStatus } from '../types/const'; import { ResultStatus } from '../types/const';
import { getCurrentLoginHistory } from '@/api/user/mine'; import { getCurrentLoginHistory } from '@/api/user/mine';
import { dateFormat } from '@/utils'; import { dateFormat } from '@/utils';
import { isMobile } from '@/utils/is';
const list = ref<LoginHistoryQueryResponse[]>([]); const list = ref<LoginHistoryQueryResponse[]>([]);
const userStore = useUserStore();
const { loading, setLoading } = useLoading(); const { loading, setLoading } = useLoading();
// 查询操作日志 // 查询操作日志
@@ -78,7 +78,7 @@
.extra-message { .extra-message {
margin-bottom: 38px; margin-bottom: 38px;
margin-left: -20px; margin-left: -24px;
display: block; display: block;
color: var(--color-text-3); color: var(--color-text-3);
user-select: none; user-select: none;
@@ -112,8 +112,9 @@
.address-line { .address-line {
color: var(--color-text-1); color: var(--color-text-1);
font-size: 16px; font-size: 15px;
font-weight: 600; font-weight: 600;
margin-bottom: 2px;
} }
.time-line, .ua-line, .error-line { .time-line, .ua-line, .error-line {

View File

@@ -0,0 +1,234 @@
<template>
<!-- 搜索 -->
<a-card class="general-card table-search-card">
<a-query-header :model="formModel"
label-align="left"
@submit="fetchTableData"
@reset="fetchTableData"
@keyup.enter="() => fetchTableData()">
<!-- 角色名称 -->
<a-form-item field="name" label="角色名称" label-col-flex="50px">
<a-input v-model="formModel.name" placeholder="请输入角色名称" allow-clear />
</a-form-item>
<!-- 角色编码 -->
<a-form-item field="code" label="角色编码" label-col-flex="50px">
<a-input v-model="formModel.code" placeholder="请输入角色编码" allow-clear />
</a-form-item>
<!-- 角色状态 -->
<a-form-item field="status" label="角色状态" label-col-flex="50px">
<a-select v-model="formModel.status"
placeholder="请选择角色状态"
:options="toOptions(roleStatusKey)"
allow-clear />
</a-form-item>
</a-query-header>
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
角色列表
</div>
</div>
<!-- 右侧操作 -->
<div class="table-right-bar-handle">
<a-space>
<!-- 新增 -->
<a-button type="primary"
v-permission="['infra:system-role:create']"
@click="emits('openAdd')">
新增
<template #icon>
<icon-plus />
</template>
</a-button>
</a-space>
</div>
</template>
<!-- table -->
<a-table row-key="id"
class="table-wrapper-8"
ref="tableRef"
label-align="left"
:loading="loading"
:columns="columns"
:data="tableRenderData"
:pagination="pagination"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)"
:bordered="false">
<!-- 编码 -->
<template #code="{ record }">
<a-tag>{{ record.code }}</a-tag>
</template>
<!-- 状态 -->
<template #status="{ record }">
<span class="circle" :style="{
background: getDictValue(roleStatusKey, record.status, 'color')
}" />
{{ getDictValue(roleStatusKey, record.status) }}
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 修改状态 -->
<a-popconfirm :content="`确定要${toggleDictValue(roleStatusKey, record.status, 'label')}当前角色吗?`"
position="left"
type="warning"
@ok="toggleRoleStatus(record)">
<a-button v-permission="['infra:system-role:delete']"
:disabled="record.code === 'admin'"
:status="toggleDictValue(roleStatusKey, record.status, 'status')"
type="text"
size="mini">
{{ toggleDictValue(roleStatusKey, record.status, 'label') }}
</a-button>
</a-popconfirm>
<!-- 分配菜单 -->
<a-button v-permission="['infra:system-role:grant-menu']"
:disabled="record.code === 'admin'"
type="text"
size="mini"
@click="emits('openGrant', record)">
分配菜单
</a-button>
<!-- 修改 -->
<a-button v-permission="['infra:system-role:update']"
type="text"
size="mini"
@click="emits('openUpdate', record)">
修改
</a-button>
<!-- 删除 -->
<a-popconfirm content="确认删除这条记录吗?"
position="left"
type="warning"
@ok="deleteRow(record)">
<a-button v-permission="['infra:system-role:delete']"
:disabled="record.code === 'admin'"
type="text"
size="mini"
status="danger">
删除
</a-button>
</a-popconfirm>
</div>
</template>
</a-table>
</a-card>
</template>
<script lang="ts">
export default {
name: 'operator-log-list'
};
</script>
<script lang="ts" setup>
import type { RoleQueryRequest, RoleQueryResponse } from '@/api/user/role';
import { reactive, ref, onMounted } from 'vue';
import { deleteRole, getRolePage, updateRoleStatus } from '@/api/user/role';
import { Message } from '@arco-design/web-vue';
import useLoading from '@/hooks/loading';
import columns from '../../role/types/table.columns';
import { roleStatusKey } from '../../role/types/const';
import { usePagination } from '@/types/table';
import { useDictStore } from '@/store';
const emits = defineEmits(['openAdd', 'openUpdate', 'openGrant']);
const tableRenderData = ref<RoleQueryResponse[]>([]);
const pagination = usePagination();
const { loading, setLoading } = useLoading();
const { toOptions, getDictValue, toggleDictValue, toggleDict } = useDictStore();
const formModel = reactive<RoleQueryRequest>({
id: undefined,
name: undefined,
code: undefined,
status: undefined,
});
// 修改状态
const toggleRoleStatus = async (record: any) => {
try {
setLoading(true);
const toggleStatus = toggleDict(roleStatusKey, record.status);
// 调用修改接口
await updateRoleStatus({
id: record.id,
status: toggleStatus.value as number
});
Message.success(`${toggleStatus.label}成功`);
// 修改行状态
record.status = toggleStatus.value;
} catch (e) {
} finally {
setLoading(false);
}
};
// 删除当前行
const deleteRow = async ({ id }: {
id: number
}) => {
try {
setLoading(true);
// 调用删除接口
await deleteRole(id);
Message.success('删除成功');
// 重新加载数据
fetchTableData();
} catch (e) {
} finally {
setLoading(false);
}
};
// 添加后回调
const addedCallback = () => {
fetchTableData();
};
// 更新后回调
const updatedCallback = () => {
fetchTableData();
};
defineExpose({
addedCallback, updatedCallback
});
// 加载数据
const doFetchTableData = async (request: RoleQueryRequest) => {
try {
setLoading(true);
const { data } = await getRolePage(request);
tableRenderData.value = data.rows;
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = formModel) => {
doFetchTableData({ page, limit, ...form });
};
onMounted(() => {
fetchTableData();
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,150 @@
<template>
<a-spin :loading="loading" class="main-container">
<span class="extra-message">所有登录设备的会话列表</span>
<a-timeline>
<template v-for="item in list"
:key="item.loginTime">
<a-timeline-item v-if="item.visible">
<!-- 图标 -->
<template #dot>
<div class="icon-container">
<icon-mobile v-if="isMobile(item.userAgent)" />
<icon-desktop v-else />
</div>
</template>
<!-- 会话行 -->
<div class="session-line">
<!-- 地址行 -->
<a-space class="address-line">
<span>{{ item.address }}</span>
<span>{{ item.location }}</span>
<a-tag v-if="item.current" color="arcoblue">当前会话</a-tag>
<a-button v-else
style="font-weight: 600;"
type="text"
size="mini"
status="danger"
@click="offline(item)">
下线
</a-button>
</a-space>
<!-- 时间行 -->
<span class="time-line">
{{ dateFormat(new Date(item.loginTime)) }}
</span>
<!-- ua -->
<span class="ua-line">
{{ item.userAgent }}
</span>
</div>
</a-timeline-item>
</template>
</a-timeline>
</a-spin>
</template>
<script lang="ts">
export default {
name: 'user-session'
};
</script>
<script lang="ts" setup>
import type { UserSessionQueryResponse } from '@/api/user/user';
import useLoading from '@/hooks/loading';
import { ref, onMounted } from 'vue';
import { getCurrentUserSessionList, offlineCurrentUserSession } from '@/api/user/mine';
import { dateFormat } from '@/utils';
import { isMobile } from '@/utils/is';
import { Message } from '@arco-design/web-vue';
const list = ref<UserSessionQueryResponse[]>([]);
const { loading, setLoading } = useLoading();
// 下线
const offline = async (item: UserSessionQueryResponse) => {
try {
setLoading(true);
await offlineCurrentUserSession({
timestamp: item.loginTime
});
Message.success('操作成功');
item.visible = false;
} catch (e) {
} finally {
setLoading(false);
}
};
// 查询登录会话
onMounted(async () => {
try {
setLoading(true);
const { data } = await getCurrentUserSessionList();
data.forEach(s => s.visible = true);
list.value = data;
} catch (e) {
} finally {
setLoading(false);
}
});
</script>
<style lang="less" scoped>
.main-container {
width: 100%;
min-height: 200px;
padding-left: 48px;
}
.extra-message {
margin-bottom: 38px;
margin-left: -24px;
display: block;
color: var(--color-text-3);
user-select: none;
}
.icon-container {
border-radius: 50%;
width: 56px;
height: 56px;
background: var(--color-fill-4);
font-size: 28px;
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
}
:deep(.arco-timeline-item-content-wrapper) {
position: relative;
margin-left: 44px;
margin-top: -22px;
}
:deep(.arco-timeline-item) {
padding-bottom: 36px;
}
.session-line {
display: flex;
flex-direction: column;
.address-line {
color: var(--color-text-1);
font-size: 15px;
font-weight: 600;
margin-bottom: 2px;
}
.time-line, .ua-line {
color: var(--color-text-3);
font-size: 14px;
margin-top: 2px;
}
}
</style>

View File

@@ -15,10 +15,11 @@
</a-tab-pane> </a-tab-pane>
<!-- 登录设备 --> <!-- 登录设备 -->
<a-tab-pane key="3" title="登录设备"> <a-tab-pane key="3" title="登录设备">
<login-history /> <user-session />
</a-tab-pane> </a-tab-pane>
<!-- 操作日志 --> <!-- 操作日志 -->
<a-tab-pane key="4" title="操作日志"> <a-tab-pane key="4" title="操作日志">
<operator-log-list />
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
</div> </div>
@@ -33,6 +34,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import UserInfo from './components/user-info.vue'; import UserInfo from './components/user-info.vue';
import LoginHistory from './components/login-history.vue'; import LoginHistory from './components/login-history.vue';
import UserSession from './components/user-session.vue';
import OperatorLogList from './components/operator-log-list.vue';
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@@ -0,0 +1,134 @@
<template>
<a-query-header :model="formModel"
label-align="left"
@submit="submit"
@reset="reset"
@keyup.enter="submit">
<!-- 操作用户 -->
<a-form-item v-if="visibleUser"
field="userId"
label="操作用户"
label-col-flex="50px">
<user-selector v-model="formModel.userId"
placeholder="请选择操作用户"
allow-clear />
</a-form-item>
<!-- 操作模块 -->
<a-form-item field="module" label="操作模块" label-col-flex="50px">
<a-select v-model="formModel.module"
:options="toOptions(operatorLogModuleKey)"
placeholder="请选择操作模块"
@change="selectedModule"
allow-clear />
</a-form-item>
<!-- 操作类型 -->
<a-form-item field="type" label="操作类型" label-col-flex="50px">
<a-select v-model="formModel.type"
:options="typeOptions"
placeholder="请选择操作类型"
allow-clear />
</a-form-item>
<!-- 风险等级 -->
<a-form-item field="riskLevel" label="风险等级" label-col-flex="50px">
<a-select v-model="formModel.riskLevel"
:options="toOptions(operatorRiskLevelKey)"
placeholder="请选择风险等级"
allow-clear />
</a-form-item>
<!-- 执行结果 -->
<a-form-item field="result" label="执行结果" label-col-flex="50px">
<a-select v-model="formModel.result"
:options="toOptions(operatorLogResultKey)"
placeholder="请选择执行结果"
allow-clear />
</a-form-item>
<!-- 执行时间 -->
<a-form-item field="startTime" label="执行时间" label-col-flex="50px">
<a-range-picker v-model="timeRange"
:time-picker-props="{ defaultValue: ['00:00:00', '23:59:59'] }"
show-time
format="YYYY-MM-DD HH:mm:ss"
@ok="timeRangePicked" />
</a-form-item>
</a-query-header>
</template>
<script lang="ts">
export default {
name: 'operator-log-query-header'
};
</script>
<script lang="ts" setup>
import type { OperatorLogQueryRequest } from '@/api/user/operator-log';
import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface';
import { reactive, ref } from 'vue';
import useLoading from '@/hooks/loading';
import { useDictStore } from '@/store';
import UserSelector from '@/components/user/role/user-selector.vue';
import { operatorLogModuleKey, operatorLogTypeKey, operatorRiskLevelKey, operatorLogResultKey } from '../types/const';
const emits = defineEmits(['submit']);
const props = defineProps({
visibleUser: {
type: Boolean,
default: true
}
});
const { loading, setLoading } = useLoading();
const { $state: dictState, toOptions } = useDictStore();
const timeRange = ref<string[]>([]);
const typeOptions = ref<SelectOptionData[]>(toOptions(operatorLogTypeKey));
const formModel = reactive<OperatorLogQueryRequest>({
userId: undefined,
module: undefined,
type: undefined,
riskLevel: undefined,
result: undefined,
startTimeStart: undefined,
startTimeEnd: undefined,
});
// 选择时间
const timeRangePicked = (e: string[]) => {
formModel.startTimeStart = e[0];
formModel.startTimeEnd = e[1];
};
// 选择类型
const selectedModule = (module: string) => {
if (!module) {
// 不选择则重置 options
typeOptions.value = toOptions(operatorLogTypeKey);
return;
}
const moduleArr = module.split(':');
const modulePrefix = moduleArr[moduleArr.length - 1] + ':';
// 渲染 options
typeOptions.value = dictState[operatorLogTypeKey].filter(s => (s.value as string).startsWith(modulePrefix));
// 渲染输入框
if (formModel.type && !formModel.type.startsWith(modulePrefix)) {
formModel.type = undefined;
}
};
// 重置
const reset = () => {
timeRange.value = [];
formModel.startTimeStart = undefined;
formModel.startTimeEnd = undefined;
submit();
};
// 切换页码
const submit = () => {
emits('submit', { ...formModel });
};
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,139 @@
<template>
<a-table row-key="id"
class="table-wrapper-8"
ref="tableRef"
label-align="left"
:loading="loading"
:columns="tableColumns"
:data="tableRenderData"
:pagination="pagination"
@page-change="(page) => fetchTableData(page, pagination.pageSize)"
@page-size-change="(size) => fetchTableData(1, size)"
:bordered="false">
<!-- 操作模块 -->
<template #module="{ record }">
{{ getDictValue(operatorLogModuleKey, record.module) }}
</template>
<!-- 操作类型 -->
<template #type="{ record }">
{{ getDictValue(operatorLogTypeKey, record.type) }}
</template>
<!-- 风险等级 -->
<template #riskLevel="{ record }">
<a-tag :color="getDictValue(operatorRiskLevelKey, record.riskLevel, 'color')">
{{ getDictValue(operatorRiskLevelKey, record.riskLevel) }}
</a-tag>
</template>
<!-- 执行结果 -->
<template #result="{ record }">
<a-tag :color="getDictValue(operatorLogResultKey, record.result, 'color')">
{{ getDictValue(operatorLogResultKey, record.result) }}
</a-tag>
</template>
<!-- 操作日志 -->
<template #originLogInfo="{ record }">
<span v-html="replaceHtmlTag(record.logInfo)" />
</template>
<!-- 操作 -->
<template #handle="{ record }">
<div class="table-handle-wrapper">
<!-- 详情 -->
<a-button type="text" size="mini" @click="viewDetail(record)">
详情
</a-button>
</div>
</template>
</a-table>
</template>
<script lang="ts">
export default {
name: 'operator-log-table'
};
</script>
<script lang="ts" setup>
import type { OperatorLogQueryRequest, OperatorLogQueryResponse } from '@/api/user/operator-log';
import { ref, onMounted } from 'vue';
import { operatorLogModuleKey, operatorLogTypeKey, operatorRiskLevelKey, operatorLogResultKey } from '../types/const';
import columns from '../types/table.columns';
import useLoading from '@/hooks/loading';
import { usePagination } from '@/types/table';
import { useDictStore } from '@/store';
import { getOperatorLogPage } from '@/api/user/operator-log';
import { replaceHtmlTag, clearHtmlTag, dateFormat } from '@/utils';
import { pick } from 'lodash';
const emits = defineEmits(['viewDetail']);
const props = defineProps({
visibleUser: {
type: Boolean,
default: true
}
});
const tableColumns = ref();
const tableRenderData = ref<OperatorLogQueryResponse[]>([]);
const pagination = usePagination();
const { loading, setLoading } = useLoading();
const { getDictValue } = useDictStore();
// 查看详情
const viewDetail = (record: OperatorLogQueryResponse) => {
try {
const detail = Object.assign({} as Record<string, any>,
pick(record, 'traceId', 'address', 'location',
'userAgent', 'errorMessage'));
detail.duration = `${record.duration} ms`;
detail.startTime = dateFormat(new Date(record.startTime));
detail.endTime = dateFormat(new Date(record.endTime));
detail.extra = JSON.parse(record?.extra);
detail.returnValue = JSON.parse(record?.returnValue);
emits('viewDetail', detail);
} catch (e) {
emits('viewDetail', record);
}
};
// 加载数据
const doFetchTableData = async (request: OperatorLogQueryRequest) => {
try {
setLoading(true);
const { data } = await getOperatorLogPage(request);
tableRenderData.value = data.rows.map(s => {
return { ...s, originLogInfo: clearHtmlTag(s.logInfo) };
});
pagination.total = data.total;
pagination.current = request.page;
pagination.pageSize = request.limit;
} catch (e) {
} finally {
setLoading(false);
}
};
// 切换页码
const fetchTableData = (page = 1, limit = pagination.pageSize, form = {}) => {
doFetchTableData({ page, limit, ...form });
};
onMounted(() => {
if (props.visibleUser) {
tableColumns.value = columns;
} else {
tableColumns.value = columns.filter(s => s.dataIndex !== 'username');
}
fetchTableData();
});
defineExpose({
fetchTableData
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div class="layout-container" v-if="render">
<!-- 查询头 -->
<a-card class="general-card table-search-card">
<!-- 查询头组件 -->
<operator-log-query-header @submit="(e) => table.fetchTableData(undefined, undefined, e)" />
</a-card>
<!-- 表格 -->
<a-card class="general-card table-card">
<template #title>
<!-- 左侧操作 -->
<div class="table-left-bar-handle">
<!-- 标题 -->
<div class="table-title">
操作日志
</div>
</div>
</template>
<!-- 表格组件 -->
<operator-log-table ref="table" @viewDetail="(e) => view.open(e)" />
</a-card>
<!-- json 查看器模态框 -->
<json-view-modal ref="view" />
</div>
</template>
<script lang="ts">
export default {
name: 'userOperatorLog'
};
</script>
<script lang="ts" setup>
import { ref, onBeforeMount, onUnmounted } from 'vue';
import { useCacheStore, useDictStore } from '@/store';
import { dictKeys } from './types/const';
import { getUserList } from '@/api/user/user';
import OperatorLogQueryHeader from './components/operator-log-query-header.vue';
import OperatorLogTable from './components/operator-log-table.vue';
import JsonViewModal from '@/components/view/json/json-view-modal.vue';
const cacheStore = useCacheStore();
const render = ref();
const table = ref();
const view = ref();
// 加载全部用户列表
const fetchUserList = async () => {
const { data } = await getUserList();
cacheStore.set('users', data);
};
onBeforeMount(async () => {
// 加载字典值
const dictStore = useDictStore();
await dictStore.loadKeys(dictKeys);
// 加载用户列表
await fetchUserList();
render.value = true;
});
// 卸载时清除 cache
onUnmounted(() => {
cacheStore.reset('users');
});
</script>
<style lang="less" scoped>
</style>

View File

@@ -0,0 +1,24 @@
// 结果状态
export const ResultStatus = {
// 失败
FAILED: 0,
// 成功
SUCCESS: 1,
};
// 操作日志模块 字典项
export const operatorLogModuleKey = 'operatorLogModule';
// 操作日志类型 字典项
export const operatorLogTypeKey = 'operatorLogType';
// 操作风险等级 字典项
export const operatorRiskLevelKey = 'operatorRiskLevel';
// 操作日志结果 字典项
export const operatorLogResultKey = 'operatorLogResult';
// 加载的字典值
export const dictKeys = [operatorLogModuleKey, operatorLogTypeKey, operatorRiskLevelKey, operatorLogResultKey];

View File

@@ -0,0 +1,69 @@
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
import { dateFormat } from '@/utils';
const columns = [
{
title: 'id',
dataIndex: 'id',
slotName: 'id',
width: 70,
align: 'left',
fixed: 'left',
}, {
title: '操作用户',
dataIndex: 'username',
slotName: 'username',
width: 120,
ellipsis: true,
tooltip: true,
}, {
title: '操作模块',
dataIndex: 'module',
slotName: 'module',
width: 120,
ellipsis: true,
tooltip: true,
}, {
title: '操作类型',
dataIndex: 'type',
slotName: 'type',
width: 150,
ellipsis: true,
tooltip: true,
}, {
title: '风险等级',
dataIndex: 'riskLevel',
slotName: 'riskLevel',
width: 90,
align: 'center',
}, {
title: '执行结果',
dataIndex: 'result',
slotName: 'result',
width: 90,
align: 'center',
}, {
title: '操作日志',
dataIndex: 'originLogInfo',
slotName: 'originLogInfo',
ellipsis: true,
tooltip: true,
}, {
title: '创建时间',
dataIndex: 'createTime',
slotName: 'createTime',
align: 'center',
width: 180,
render: ({ record }) => {
return dateFormat(new Date(record.createTime));
},
}, {
title: '操作',
slotName: 'handle',
width: 90,
align: 'center',
fixed: 'right',
},
] as TableColumnData[];
export default columns;

View File

@@ -12,7 +12,7 @@
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
}, },
"lib": ["es2020", "dom"], "lib": ["es2021", "dom"],
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src/**/*", "src/**/*.vue"], "include": ["src/**/*", "src/**/*.vue"],