From 3116fd3eaec28911a0ec252b83a10a42c0661a78 Mon Sep 17 00:00:00 2001
From: Charles7c <charles7c@126.com>
Date: Sat, 21 Jan 2023 14:15:00 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E6=96=B0=E5=A2=9E?=
 =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E7=9B=91=E6=8E=A7/=E5=9C=A8=E7=BA=BF?=
 =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=B9=B6=E4=BC=98?=
 =?UTF-8?q?=E5=8C=96=E9=83=A8=E5=88=86=E6=B3=A8=E9=87=8A=E8=A7=84=E8=8C=83?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 README.md                                     |   9 +-
 .../handler/GlobalExceptionHandler.java       |  15 +-
 .../cnadmin/common/model/dto/LoginUser.java   |  25 +++
 .../cnadmin/common/model/query/PageQuery.java |   4 +
 .../cnadmin/common/model/vo/PageInfo.java     |  35 +++
 .../common/util/helper/LoginHelper.java       |  25 ++-
 .../cnadmin/monitor/model/entity/SysLog.java  |   2 +-
 .../monitor/model/query/OnlineUserQuery.java  |  52 +++++
 .../cnadmin/monitor/model/vo/LoginLogVO.java  |   8 +-
 .../monitor/model/vo/OnlineUserVO.java        |  79 +++++++
 .../monitor/model/vo/OperationLogVO.java      |   4 +-
 .../monitor/model/vo/SystemLogDetailVO.java   |   4 +-
 .../cnadmin/monitor/model/vo/SystemLogVO.java |   4 +-
 .../cnadmin/monitor/service/LogService.java   |   2 +-
 continew-admin-ui/src/api/monitor/online.ts   |  35 +++
 continew-admin-ui/src/locale/en-US.ts         |   2 +
 continew-admin-ui/src/locale/zh-CN.ts         |   2 +
 .../src/router/routes/modules/monitor.ts      |  10 +
 .../src/views/monitor/log/login/index.vue     |  16 +-
 .../src/views/monitor/log/operation/index.vue |  21 +-
 .../src/views/monitor/online/index.vue        | 202 ++++++++++++++++++
 .../src/views/monitor/online/locale/en-US.ts  |   3 +
 .../src/views/monitor/online/locale/zh-CN.ts  |   3 +
 .../monitor/OnlineUserController.java         | 126 +++++++++++
 24 files changed, 640 insertions(+), 48 deletions(-)
 create mode 100644 continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/query/OnlineUserQuery.java
 create mode 100644 continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/OnlineUserVO.java
 create mode 100644 continew-admin-ui/src/api/monitor/online.ts
 create mode 100644 continew-admin-ui/src/views/monitor/online/index.vue
 create mode 100644 continew-admin-ui/src/views/monitor/online/locale/en-US.ts
 create mode 100644 continew-admin-ui/src/views/monitor/online/locale/zh-CN.ts
 create mode 100644 continew-admin-webapi/src/main/java/top/charles7c/cnadmin/webapi/controller/monitor/OnlineUserController.java

diff --git a/README.md b/README.md
index a929c809..851b81ae 100644
--- a/README.md
+++ b/README.md
@@ -251,10 +251,11 @@ continew-admin
     │  ├─ views             # 页面模板
     │  │  ├─ login            # 登录模块
     │  │  ├─ monitor          # 系统监控模块
-    │  │  │ └─ log              # 日志管理
-    │  │  │   ├─ login            # 登录日志
-    │  │  │   ├─ operation        # 操作日志
-    │  │  │   └─ system           # 系统日志
+    │  │  │  ├─ log              # 日志管理
+    │  │  │  │  ├─ login            # 登录日志
+    │  │  │  │  ├─ operation        # 操作日志
+    │  │  │  │  └─ system           # 系统日志
+    │  │  │  └─ online           # 在线用户
     │  │  └─ system           # 系统管理模块
     │  │    └─ user             # 用户模块
     │  │      └─ center           # 个人中心
diff --git a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/handler/GlobalExceptionHandler.java b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/handler/GlobalExceptionHandler.java
index 749a091d..b7da3af1 100644
--- a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/handler/GlobalExceptionHandler.java
+++ b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/handler/GlobalExceptionHandler.java
@@ -169,7 +169,20 @@ public class GlobalExceptionHandler {
     @ExceptionHandler(NotLoginException.class)
     public R handleNotLoginException(NotLoginException e, HttpServletRequest request) {
         log.error("请求地址'{}',认证失败,无法访问系统资源", request.getRequestURI(), e);
-        String errorMsg = "登录状态已过期,请重新登录";
+
+        String errorMsg;
+        switch (e.getType()) {
+            case NotLoginException.KICK_OUT:
+                errorMsg = "您已被踢下线";
+                break;
+            case NotLoginException.BE_REPLACED_MESSAGE:
+                errorMsg = "您已被顶下线";
+                break;
+            default:
+                errorMsg = "登录状态已过期,请重新登录";
+                break;
+        }
+
         LogContextHolder.setErrorMsg(errorMsg);
         return R.fail(HttpStatus.UNAUTHORIZED.value(), errorMsg);
     }
diff --git a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/dto/LoginUser.java b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/dto/LoginUser.java
index 801b931d..368d0842 100644
--- a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/dto/LoginUser.java
+++ b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/dto/LoginUser.java
@@ -83,4 +83,29 @@ public class LoginUser implements Serializable {
      * 创建时间
      */
     private LocalDateTime createTime;
+
+    /**
+     * 令牌
+     */
+    private String token;
+
+    /**
+     * 登录 IP
+     */
+    private String clientIp;
+
+    /**
+     * 登录地点
+     */
+    private String location;
+
+    /**
+     * 浏览器
+     */
+    private String browser;
+
+    /**
+     * 登录时间
+     */
+    private LocalDateTime loginTime;
 }
diff --git a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/query/PageQuery.java b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/query/PageQuery.java
index 61fed926..a8643987 100644
--- a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/query/PageQuery.java
+++ b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/query/PageQuery.java
@@ -78,6 +78,10 @@ public class PageQuery implements Serializable {
         this.size = DEFAULT_SIZE;
     }
 
+    public int getPage() {
+        return page < 0 ? DEFAULT_PAGE : page;
+    }
+
     /**
      * 解析排序条件为 Spring 分页排序实体
      *
diff --git a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/vo/PageInfo.java b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/vo/PageInfo.java
index b8ae4f58..88f4a90a 100644
--- a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/vo/PageInfo.java
+++ b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/model/vo/PageInfo.java
@@ -16,6 +16,7 @@
 
 package top.charles7c.cnadmin.common.model.vo;
 
+import java.util.ArrayList;
 import java.util.List;
 
 import lombok.Data;
@@ -26,6 +27,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 
 import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.collection.CollUtil;
 
 /**
  * 分页信息
@@ -93,4 +95,37 @@ public class PageInfo<V> {
         pageInfo.setTotal(pageInfo.getTotal());
         return pageInfo;
     }
+
+    /**
+     * 基于列表数据构建分页信息
+     *
+     * @param page
+     *            页码
+     * @param size
+     *            每页记录数
+     * @param list
+     *            列表数据
+     * @param <V>
+     *            列表数据类型
+     * @return 分页信息
+     */
+    public static <V> PageInfo<V> build(int page, int size, List<V> list) {
+        PageInfo<V> pageInfo = new PageInfo<>();
+        if (CollUtil.isEmpty(list)) {
+            return pageInfo;
+        }
+
+        pageInfo.setTotal(list.size());
+        // 对列表数据进行分页
+        int fromIndex = (page - 1) * size;
+        int toIndex = page * size + size;
+        if (fromIndex > list.size()) {
+            pageInfo.setList(new ArrayList<>());
+        } else if (toIndex >= list.size()) {
+            pageInfo.setList(list.subList(fromIndex, list.size()));
+        } else {
+            pageInfo.setList(list.subList(fromIndex, toIndex));
+        }
+        return pageInfo;
+    }
 }
diff --git a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/util/helper/LoginHelper.java b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/util/helper/LoginHelper.java
index 62655f93..38adc865 100644
--- a/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/util/helper/LoginHelper.java
+++ b/continew-admin-common/src/main/java/top/charles7c/cnadmin/common/util/helper/LoginHelper.java
@@ -16,15 +16,24 @@
 
 package top.charles7c.cnadmin.common.util.helper;
 
+import java.time.LocalDateTime;
+
+import javax.servlet.http.HttpServletRequest;
+
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
 
 import cn.dev33.satoken.context.SaHolder;
 import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.extra.servlet.ServletUtil;
 
 import top.charles7c.cnadmin.common.consts.CacheConstants;
+import top.charles7c.cnadmin.common.model.dto.LogContext;
 import top.charles7c.cnadmin.common.model.dto.LoginUser;
 import top.charles7c.cnadmin.common.util.ExceptionUtils;
+import top.charles7c.cnadmin.common.util.IpUtils;
+import top.charles7c.cnadmin.common.util.ServletUtils;
+import top.charles7c.cnadmin.common.util.holder.LogContextHolder;
 
 /**
  * 登录助手
@@ -42,8 +51,22 @@ public class LoginHelper {
      *            登录用户信息
      */
     public static void login(LoginUser loginUser) {
-        SaHolder.getStorage().set(CacheConstants.LOGIN_USER_CACHE_KEY, loginUser);
+        if (loginUser == null) {
+            return;
+        }
+
+        // 记录登录信息
+        HttpServletRequest request = ServletUtils.getRequest();
+        loginUser.setClientIp(ServletUtil.getClientIP(request));
+        loginUser.setLocation(IpUtils.getCityInfo(loginUser.getClientIp()));
+        loginUser.setBrowser(ServletUtils.getBrowser(request));
+        LogContext logContext = LogContextHolder.get();
+        loginUser.setLoginTime(logContext != null ? logContext.getCreateTime() : LocalDateTime.now());
+
+        // 登录保存用户信息
         StpUtil.login(loginUser.getUserId());
+        loginUser.setToken(StpUtil.getTokenValue());
+        SaHolder.getStorage().set(CacheConstants.LOGIN_USER_CACHE_KEY, loginUser);
         StpUtil.getTokenSession().set(CacheConstants.LOGIN_USER_CACHE_KEY, loginUser);
     }
 
diff --git a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/entity/SysLog.java b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/entity/SysLog.java
index 022d48df..8c27d263 100644
--- a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/entity/SysLog.java
+++ b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/entity/SysLog.java
@@ -39,7 +39,7 @@ public class SysLog implements Serializable {
     private static final long serialVersionUID = 1L;
 
     /**
-     * 日志ID
+     * 日志 ID
      */
     @TableId
     private Long logId;
diff --git a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/query/OnlineUserQuery.java b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/query/OnlineUserQuery.java
new file mode 100644
index 00000000..60164218
--- /dev/null
+++ b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/query/OnlineUserQuery.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.charles7c.cnadmin.monitor.model.query;
+
+import java.util.Date;
+import java.util.List;
+
+import lombok.Data;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import org.springdoc.api.annotations.ParameterObject;
+import org.springframework.format.annotation.DateTimeFormat;
+
+/**
+ * 在线用户查询条件
+ *
+ * @author Charles7c
+ * @since 2023/1/20 23:07
+ */
+@Data
+@ParameterObject
+@Schema(description = "在线用户查询条件")
+public class OnlineUserQuery {
+
+    /**
+     * 用户昵称
+     */
+    @Schema(description = "用户昵称")
+    private String nickname;
+
+    /**
+     * 登录时间
+     */
+    @Schema(description = "登录时间")
+    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private List<Date> loginTime;
+}
diff --git a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/LoginLogVO.java b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/LoginLogVO.java
index afbab97a..a9e6e615 100644
--- a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/LoginLogVO.java
+++ b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/LoginLogVO.java
@@ -38,9 +38,9 @@ public class LoginLogVO extends LogVO implements Serializable {
     private static final long serialVersionUID = 1L;
 
     /**
-     * 日志ID
+     * 日志 ID
      */
-    @Schema(description = "日志ID")
+    @Schema(description = "日志 ID")
     private Long logId;
 
     /**
@@ -56,9 +56,9 @@ public class LoginLogVO extends LogVO implements Serializable {
     private LogStatusEnum status;
 
     /**
-     * 登录IP
+     * 登录 IP
      */
-    @Schema(description = "登录IP")
+    @Schema(description = "登录 IP")
     private String clientIp;
 
     /**
diff --git a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/OnlineUserVO.java b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/OnlineUserVO.java
new file mode 100644
index 00000000..24b92a38
--- /dev/null
+++ b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/OnlineUserVO.java
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.charles7c.cnadmin.monitor.model.vo;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+import lombok.Data;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * 在线用户信息
+ *
+ * @author Charles7c
+ * @since 2023/1/20 21:54
+ */
+@Data
+@Schema(description = "在线用户信息")
+public class OnlineUserVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 令牌
+     */
+    @Schema(description = "令牌")
+    private String token;
+
+    /**
+     * 用户名
+     */
+    @Schema(description = "用户名")
+    private String username;
+
+    /**
+     * 昵称
+     */
+    @Schema(description = "昵称")
+    private String nickname;
+
+    /**
+     * 登录 IP
+     */
+    @Schema(description = "登录 IP")
+    private String clientIp;
+
+    /**
+     * 登录地点
+     */
+    @Schema(description = "登录地点")
+    private String location;
+
+    /**
+     * 浏览器
+     */
+    @Schema(description = "浏览器")
+    private String browser;
+
+    /**
+     * 登录时间
+     */
+    @Schema(description = "登录时间")
+    private LocalDateTime loginTime;
+}
diff --git a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/OperationLogVO.java b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/OperationLogVO.java
index 6b8f8719..0b0dbf4a 100644
--- a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/OperationLogVO.java
+++ b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/OperationLogVO.java
@@ -38,9 +38,9 @@ public class OperationLogVO extends LogVO implements Serializable {
     private static final long serialVersionUID = 1L;
 
     /**
-     * 日志ID
+     * 日志 ID
      */
-    @Schema(description = "日志ID")
+    @Schema(description = "日志 ID")
     private Long logId;
 
     /**
diff --git a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/SystemLogDetailVO.java b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/SystemLogDetailVO.java
index 7164828b..2c0285e8 100644
--- a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/SystemLogDetailVO.java
+++ b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/SystemLogDetailVO.java
@@ -36,9 +36,9 @@ public class SystemLogDetailVO extends LogVO implements Serializable {
     private static final long serialVersionUID = 1L;
 
     /**
-     * 日志ID
+     * 日志 ID
      */
-    @Schema(description = "日志ID")
+    @Schema(description = "日志 ID")
     private Long logId;
 
     /**
diff --git a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/SystemLogVO.java b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/SystemLogVO.java
index 5df0b05e..8a6c11ef 100644
--- a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/SystemLogVO.java
+++ b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/model/vo/SystemLogVO.java
@@ -36,9 +36,9 @@ public class SystemLogVO extends LogVO implements Serializable {
     private static final long serialVersionUID = 1L;
 
     /**
-     * 日志ID
+     * 日志 ID
      */
-    @Schema(description = "日志ID")
+    @Schema(description = "日志 ID")
     private Long logId;
 
     /**
diff --git a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/service/LogService.java b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/service/LogService.java
index 7d0bf7df..84f3feea 100644
--- a/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/service/LogService.java
+++ b/continew-admin-monitor/src/main/java/top/charles7c/cnadmin/monitor/service/LogService.java
@@ -71,7 +71,7 @@ public interface LogService {
      * 查看系统日志详情
      *
      * @param logId
-     *            日志ID
+     *            日志 ID
      * @return 系统日志详情
      */
     SystemLogDetailVO detail(Long logId);
diff --git a/continew-admin-ui/src/api/monitor/online.ts b/continew-admin-ui/src/api/monitor/online.ts
new file mode 100644
index 00000000..77501928
--- /dev/null
+++ b/continew-admin-ui/src/api/monitor/online.ts
@@ -0,0 +1,35 @@
+import axios from 'axios';
+import qs from 'query-string';
+
+export interface OnlineUserRecord {
+  token: string;
+  username: string;
+  nickname: string;
+  clientIp: string;
+  location: string;
+  browser: string;
+  loginTime: string;
+}
+
+export interface OnlineUserParams extends Partial<OnlineUserRecord> {
+  page: number;
+  size: number;
+  sort: Array<string>;
+}
+export interface OnlineUserListRes {
+  list: OnlineUserRecord[];
+  total: number;
+}
+
+export function queryOnlineUserList(params: OnlineUserParams) {
+  return axios.get<OnlineUserListRes>('/monitor/online/user', {
+    params,
+    paramsSerializer: (obj) => {
+      return qs.stringify(obj);
+    },
+  });
+}
+
+export function kickout(token: string) {
+  return axios.delete(`/monitor/online/user/${token}`);
+}
\ No newline at end of file
diff --git a/continew-admin-ui/src/locale/en-US.ts b/continew-admin-ui/src/locale/en-US.ts
index 2491c3ce..d33820e0 100644
--- a/continew-admin-ui/src/locale/en-US.ts
+++ b/continew-admin-ui/src/locale/en-US.ts
@@ -8,6 +8,7 @@ import localeMonitor from '@/views/dashboard/monitor/locale/en-US';
 import localeDataAnalysis from '@/views/visualization/data-analysis/locale/en-US';
 import localeMultiDAnalysis from '@/views/visualization/multi-dimension-data-analysis/locale/en-US';
 
+import localeOnlineUser from '@/views/monitor/online/locale/en-US';
 import localeLoginLog from '@/views/monitor/log/login/locale/en-US';
 import localeOperationLog from '@/views/monitor/log/operation/locale/en-US';
 import localeSystemLog from '@/views/monitor/log/system/locale/en-US';
@@ -57,6 +58,7 @@ export default {
   ...localeDataAnalysis,
   ...localeMultiDAnalysis,
 
+  ...localeOnlineUser,
   ...localeLoginLog,
   ...localeOperationLog,
   ...localeSystemLog,
diff --git a/continew-admin-ui/src/locale/zh-CN.ts b/continew-admin-ui/src/locale/zh-CN.ts
index 27fce54a..c200ef44 100644
--- a/continew-admin-ui/src/locale/zh-CN.ts
+++ b/continew-admin-ui/src/locale/zh-CN.ts
@@ -8,6 +8,7 @@ import localeMonitor from '@/views/dashboard/monitor/locale/zh-CN';
 import localeDataAnalysis from '@/views/visualization/data-analysis/locale/zh-CN';
 import localeMultiDAnalysis from '@/views/visualization/multi-dimension-data-analysis/locale/zh-CN';
 
+import localeOnlineUser from '@/views/monitor/online/locale/zh-CN';
 import localeLoginLog from '@/views/monitor/log/login/locale/zh-CN';
 import localeOperationLog from '@/views/monitor/log/operation/locale/zh-CN';
 import localeSystemLog from '@/views/monitor/log/system/locale/zh-CN';
@@ -57,6 +58,7 @@ export default {
   ...localeDataAnalysis,
   ...localeMultiDAnalysis,
 
+  ...localeOnlineUser,
   ...localeLoginLog,
   ...localeOperationLog,
   ...localeSystemLog,
diff --git a/continew-admin-ui/src/router/routes/modules/monitor.ts b/continew-admin-ui/src/router/routes/modules/monitor.ts
index 9ad616ff..0bd5bd1b 100644
--- a/continew-admin-ui/src/router/routes/modules/monitor.ts
+++ b/continew-admin-ui/src/router/routes/modules/monitor.ts
@@ -12,6 +12,16 @@ const Monitor: AppRouteRecordRaw = {
     order: 2,
   },
   children: [
+    {
+      path: '/online',
+      name: 'OnlineUser',
+      component: () => import('@/views/monitor/online/index.vue'),
+      meta: {
+        locale: 'menu.online.user.list',
+        requiresAuth: true,
+        roles: ['*'],
+      },
+    },
     {
       path: 'log/login',
       name: 'LoginLog',
diff --git a/continew-admin-ui/src/views/monitor/log/login/index.vue b/continew-admin-ui/src/views/monitor/log/login/index.vue
index f68dede2..3eee355b 100644
--- a/continew-admin-ui/src/views/monitor/log/login/index.vue
+++ b/continew-admin-ui/src/views/monitor/log/login/index.vue
@@ -4,15 +4,8 @@
     <a-card class="general-card" :title="$t('menu.log.login.list')">
       <a-row style="margin-bottom: 15px">
         <a-col :span="24">
-          <a-form
-            ref="queryFormRef"
-            :model="queryFormData"
-            layout="inline"
-          >
-            <a-form-item
-              field="status"
-              hide-label
-            >
+          <a-form ref="queryFormRef" :model="queryFormData" layout="inline">
+            <a-form-item field="status" hide-label>
               <a-select
                 v-model="queryFormData.status"
                 :options="statusOptions"
@@ -21,10 +14,7 @@
                 style="width: 150px;"
               />
             </a-form-item>
-            <a-form-item
-              field="createTime"
-              hide-label
-            >
+            <a-form-item field="createTime" hide-label>
               <date-range-picker v-model="queryFormData.createTime" />
             </a-form-item>
             <a-button type="primary" @click="toQuery">
diff --git a/continew-admin-ui/src/views/monitor/log/operation/index.vue b/continew-admin-ui/src/views/monitor/log/operation/index.vue
index bb55b582..afef8e9f 100644
--- a/continew-admin-ui/src/views/monitor/log/operation/index.vue
+++ b/continew-admin-ui/src/views/monitor/log/operation/index.vue
@@ -4,15 +4,8 @@
     <a-card class="general-card" :title="$t('menu.log.operation.list')">
       <a-row style="margin-bottom: 15px">
         <a-col :span="24">
-          <a-form
-            ref="queryFormRef"
-            :model="queryFormData"
-            layout="inline"
-          >
-            <a-form-item
-              field="description"
-              hide-label
-            >
+          <a-form ref="queryFormRef" :model="queryFormData" layout="inline">
+            <a-form-item field="description" hide-label>
               <a-input
                 v-model="queryFormData.description"
                 placeholder="输入操作内容搜索"
@@ -21,10 +14,7 @@
                 @press-enter="toQuery"
               />
             </a-form-item>
-            <a-form-item
-              field="status"
-              hide-label
-            >
+            <a-form-item field="status" hide-label>
               <a-select
                 v-model="queryFormData.status"
                 :options="statusOptions"
@@ -33,10 +23,7 @@
                 style="width: 150px;"
               />
             </a-form-item>
-            <a-form-item
-              field="createTime"
-              hide-label
-            >
+            <a-form-item field="createTime" hide-label>
               <date-range-picker v-model="queryFormData.createTime" />
             </a-form-item>
             <a-button type="primary" @click="toQuery">
diff --git a/continew-admin-ui/src/views/monitor/online/index.vue b/continew-admin-ui/src/views/monitor/online/index.vue
new file mode 100644
index 00000000..5e0e43c7
--- /dev/null
+++ b/continew-admin-ui/src/views/monitor/online/index.vue
@@ -0,0 +1,202 @@
+<template>
+  <div class="container">
+    <Breadcrumb :items="['menu.monitor', 'menu.online.user.list']" />
+    <a-card class="general-card" :title="$t('menu.online.user.list')">
+      <a-row style="margin-bottom: 15px">
+        <a-col :span="24">
+          <a-form ref="queryFormRef" :model="queryFormData" layout="inline">
+            <a-form-item field="nickname" hide-label>
+              <a-input
+                v-model="queryFormData.nickname"
+                placeholder="输入用户昵称搜索"
+                allow-clear
+                style="width: 150px;"
+                @press-enter="toQuery"
+              />
+            </a-form-item>
+            <a-form-item field="loginTime" hide-label>
+              <date-range-picker v-model="queryFormData.loginTime" />
+            </a-form-item>
+            <a-button type="primary" @click="toQuery">
+              <template #icon>
+                <icon-search />
+              </template>
+              查询
+            </a-button>
+            <a-button @click="resetQuery">
+              <template #icon>
+                <icon-refresh />
+              </template>
+              重置
+            </a-button>
+          </a-form>
+        </a-col>
+      </a-row>
+      <a-table
+        :columns="columns"
+        :data="renderData"
+        :pagination="paginationProps"
+        row-key="logId"
+        :bordered="false"
+        :stripe="true"
+        :loading="loading"
+        size="large"
+        @page-change="handlePageChange"
+        @page-size-change="handlePageSizeChange"
+      >
+        <template #index="{ rowIndex }">
+          {{ rowIndex + 1 + (pagination.current - 1) * pagination.pageSize }}
+        </template>
+        <template #nickname="{ record }">
+          {{ record.nickname }}({{record.username}})
+        </template>
+        <template #operations="{ record }">
+          <a-button
+            v-permission="['admin']"
+            type="text"
+            size="small"
+            :title="currentToken === record.token ? '不能强退当前登录' : ''"
+            :disabled="currentToken === record.token"
+            @click="handleClick(record.token)"
+          >
+            强退
+          </a-button>
+        </template>
+      </a-table>
+    </a-card>
+  </div>
+</template>
+
+<script lang="ts" setup>
+  import { computed, ref, reactive } from 'vue';
+  import useLoading from '@/hooks/loading';
+  import { Message } from '@arco-design/web-vue';
+  import { queryOnlineUserList, OnlineUserRecord, OnlineUserParams, kickout } from '@/api/monitor/online';
+  import { Pagination } from '@/types/global';
+  import { PaginationProps } from '@arco-design/web-vue';
+  import type { TableColumnData } from '@arco-design/web-vue/es/table/interface';
+  import { FormInstance } from '@arco-design/web-vue/es/form';
+  import { getToken } from '@/utils/auth';
+
+  const { loading, setLoading } = useLoading(true);
+  const currentToken = computed(() => getToken());
+  const queryFormRef = ref<FormInstance>();
+  const queryFormData = ref({
+    nickname: '',
+    status: undefined,
+    loginTime: [],
+  });
+
+  // 查询
+  const toQuery = () => {
+    fetchData({
+      page: pagination.current,
+      size: pagination.pageSize,
+      sort: ['createTime,desc'],
+      ...queryFormData.value,
+    } as unknown as OnlineUserParams);
+  };
+
+  // 重置
+  const resetQuery = async () => {
+    await queryFormRef.value?.resetFields();
+    await fetchData();
+  };
+
+  const renderData = ref<OnlineUserRecord[]>([]);
+  const basePagination: Pagination = {
+    current: 1,
+    pageSize: 10,
+  };
+  const pagination = reactive({
+    ...basePagination,
+  });
+  const paginationProps = computed((): PaginationProps => {
+    return {
+      showTotal: true,
+      showPageSize: true,
+      total: pagination.total,
+      current: pagination.current,
+    }
+  });
+  const columns = computed<TableColumnData[]>(() => [
+    {
+      title: '序号',
+      dataIndex: 'index',
+      slotName: 'index',
+    },
+    {
+      title: '用户昵称',
+      dataIndex: 'nickname',
+      slotName: 'nickname',
+    },
+    {
+      title: '登录 IP',
+      dataIndex: 'clientIp',
+    },
+    {
+      title: '登录地点',
+      dataIndex: 'location',
+    },
+    {
+      title: '浏览器',
+      dataIndex: 'browser',
+    },
+    {
+      title: '登录时间',
+      dataIndex: 'loginTime',
+    },
+    {
+      title: '操作',
+      slotName: 'operations',
+      align: 'center',
+    },
+  ]);
+
+  // 分页查询列表
+  const fetchData = async (
+    params: OnlineUserParams = { page: 1, size: 10, sort: ['createTime,desc'] }
+  ) => {
+    setLoading(true);
+    try {
+      const { data } = await queryOnlineUserList(params);
+      renderData.value = data.list;
+      pagination.current = params.page;
+      pagination.total = data.total;
+    } finally {
+      setLoading(false);
+    }
+  };
+  const handlePageChange = (current: number) => {
+    fetchData({ page: current, size: pagination.pageSize, sort: ['createTime,desc'] });
+  };
+  const handlePageSizeChange = (pageSize: number) => {
+    fetchData({ page: pagination.current, size: pageSize, sort: ['createTime,desc'] });
+  };
+  fetchData();
+
+  // 强退
+  const handleClick = async (token: string) => {
+    const res = await kickout(token);
+    if (res.success) Message.success(res.msg);
+  };
+</script>
+
+<script lang="ts">
+  export default {
+    name: 'OnlineUser',
+  };
+</script>
+
+<style scoped lang="less">
+  .container {
+    padding: 0 20px 20px 20px;
+  }
+  :deep(.arco-table-th) {
+    &:last-child {
+      .arco-table-th-item-title {
+        margin-left: 16px;
+      }
+    }
+  }
+</style>
diff --git a/continew-admin-ui/src/views/monitor/online/locale/en-US.ts b/continew-admin-ui/src/views/monitor/online/locale/en-US.ts
new file mode 100644
index 00000000..ea23c972
--- /dev/null
+++ b/continew-admin-ui/src/views/monitor/online/locale/en-US.ts
@@ -0,0 +1,3 @@
+export default {
+  'menu.online.user.list': 'Online user',
+};
diff --git a/continew-admin-ui/src/views/monitor/online/locale/zh-CN.ts b/continew-admin-ui/src/views/monitor/online/locale/zh-CN.ts
new file mode 100644
index 00000000..bab3974c
--- /dev/null
+++ b/continew-admin-ui/src/views/monitor/online/locale/zh-CN.ts
@@ -0,0 +1,3 @@
+export default {
+  'menu.online.user.list': '在线用户',
+};
diff --git a/continew-admin-webapi/src/main/java/top/charles7c/cnadmin/webapi/controller/monitor/OnlineUserController.java b/continew-admin-webapi/src/main/java/top/charles7c/cnadmin/webapi/controller/monitor/OnlineUserController.java
new file mode 100644
index 00000000..5a4282b0
--- /dev/null
+++ b/continew-admin-webapi/src/main/java/top/charles7c/cnadmin/webapi/controller/monitor/OnlineUserController.java
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2022-present Charles7c Authors. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package top.charles7c.cnadmin.webapi.controller.monitor;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.List;
+
+import lombok.RequiredArgsConstructor;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+import org.springframework.http.MediaType;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import cn.dev33.satoken.dao.SaTokenDao;
+import cn.dev33.satoken.session.SaSession;
+import cn.dev33.satoken.stp.StpUtil;
+import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.date.DateUtil;
+import cn.hutool.core.util.StrUtil;
+
+import top.charles7c.cnadmin.common.consts.CacheConstants;
+import top.charles7c.cnadmin.common.model.dto.LoginUser;
+import top.charles7c.cnadmin.common.model.query.PageQuery;
+import top.charles7c.cnadmin.common.model.vo.PageInfo;
+import top.charles7c.cnadmin.common.model.vo.R;
+import top.charles7c.cnadmin.common.util.validate.ValidationUtils;
+import top.charles7c.cnadmin.monitor.model.query.OnlineUserQuery;
+import top.charles7c.cnadmin.monitor.model.vo.*;
+
+/**
+ * 在线用户 API
+ *
+ * @author Charles7c
+ * @since 2023/1/20 21:51
+ */
+@Tag(name = "在线用户 API")
+@Validated
+@RestController
+@RequiredArgsConstructor
+@RequestMapping(value = "/monitor/online/user", produces = MediaType.APPLICATION_JSON_VALUE)
+public class OnlineUserController {
+
+    @Operation(summary = "分页查询在线用户列表")
+    @GetMapping
+    public R<PageInfo<OnlineUserVO>> list(@Validated OnlineUserQuery query, @Validated PageQuery pageQuery) {
+        List<LoginUser> loginUserList = new ArrayList<>();
+        List<String> tokenKeyList = StpUtil.searchTokenValue("", 0, -1, false);
+        for (String tokenKey : tokenKeyList) {
+            String token = StrUtil.subAfter(tokenKey, ":", true);
+            // 忽略已过期或失效 token
+            if (StpUtil.stpLogic.getTokenActivityTimeoutByToken(token) < SaTokenDao.NEVER_EXPIRE) {
+                continue;
+            }
+
+            // 获取 Token Session
+            SaSession saSession = StpUtil.getTokenSessionByToken(token);
+            LoginUser loginUser = saSession.get(CacheConstants.LOGIN_USER_CACHE_KEY, new LoginUser());
+
+            // 检查是否符合查询条件
+            if (Boolean.TRUE.equals(checkQuery(query, loginUser))) {
+                loginUserList.add(loginUser);
+            }
+        }
+
+        // 构建分页数据
+        List<OnlineUserVO> onlineUserList = BeanUtil.copyToList(loginUserList, OnlineUserVO.class);
+        CollUtil.sort(onlineUserList, Comparator.comparing(OnlineUserVO::getLoginTime).reversed());
+        PageInfo<OnlineUserVO> pageInfo = PageInfo.build(pageQuery.getPage(), pageQuery.getSize(), onlineUserList);
+        return R.ok(pageInfo);
+    }
+
+    /**
+     * 检查是否符合查询条件
+     *
+     * @param query
+     *            查询条件
+     * @param loginUser
+     *            登录用户信息
+     * @return 是否符合查询条件
+     */
+    private boolean checkQuery(OnlineUserQuery query, LoginUser loginUser) {
+        boolean flag1 = true;
+        String nickname = query.getNickname();
+        if (StrUtil.isNotBlank(nickname)) {
+            flag1 = loginUser.getUsername().contains(nickname) || loginUser.getNickname().contains(nickname);
+        }
+
+        boolean flag2 = true;
+        List<Date> loginTime = query.getLoginTime();
+        if (CollUtil.isNotEmpty(loginTime)) {
+            flag2 =
+                DateUtil.isIn(DateUtil.date(loginUser.getLoginTime()).toJdkDate(), loginTime.get(0), loginTime.get(1));
+        }
+        return flag1 && flag2;
+    }
+
+    @Operation(summary = "强退在线用户")
+    @DeleteMapping("/{token}")
+    public R kickout(@PathVariable String token) {
+        String currentToken = StpUtil.getTokenValue();
+        ValidationUtils.throwIfEqual(token, currentToken, "不能强退当前登录");
+
+        StpUtil.kickoutByTokenValue(token);
+        return R.ok("强退成功");
+    }
+}