Compare commits

..

3 Commits

Author SHA1 Message Date
Leo
e5ad68f1ff feat(监控): 实现在线用户监控页面
- 新增在线用户监控页面,采用表格布局
- 支持实时查看在线用户信息(头像、账号、姓名、IP、地址等)
- 实现搜索功能,支持按登录账号、用户姓名、登录IP搜索
- 集成自动刷新功能,30秒间隔自动更新数据
- 显示在线用户统计信息和最后更新时间
- 支持强制注销操作,带权限控制和确认对话框
- 实现完整的分页功能
- 添加多选checkbox功能,支持批量操作
- 包含完整的表格样式和交互效果
2025-09-27 17:50:16 +08:00
Leo
27f9622b17 feat(API): 新增在线用户监控API模块
- 新增在线用户相关类型定义
- 实现分页查询在线用户列表接口
- 实现强制注销用户接口
- 实现获取在线用户统计接口
- 支持按登录名、用户名、IP地址过滤
2025-09-27 17:49:38 +08:00
Leo
99ef1f1f61 fix(权限): 修复在线用户监控权限配置
- 重新添加ONLINE权限配置到权限常量中
- 包含LIST和LOGOUT权限
- 更新权限组合配置
2025-09-27 17:49:00 +08:00
4 changed files with 831 additions and 0 deletions

View File

@ -64,6 +64,12 @@ export const PERMISSIONS = {
UPDATE: 'system:sysPicture:update', UPDATE: 'system:sysPicture:update',
DELETE: 'system:sysPicture:delete', DELETE: 'system:sysPicture:delete',
}, },
// 在线用户监控权限
ONLINE: {
LIST: 'monitor:online:list',
LOGOUT: 'monitor:online:logout',
},
} as const } as const
// 权限类型推断 // 权限类型推断
@ -112,6 +118,12 @@ export const PERMISSION_GROUPS = {
PERMISSIONS.PICTURE.DELETE, PERMISSIONS.PICTURE.DELETE,
], ],
// 在线用户监控相关权限
ONLINE_MANAGEMENT: [
PERMISSIONS.ONLINE.LIST,
PERMISSIONS.ONLINE.LOGOUT,
],
// 系统管理员权限(包含所有权限) // 系统管理员权限(包含所有权限)
SYSTEM_ADMIN: [ SYSTEM_ADMIN: [
...Object.values(PERMISSIONS.USER), ...Object.values(PERMISSIONS.USER),
@ -121,5 +133,6 @@ export const PERMISSION_GROUPS = {
...Object.values(PERMISSIONS.OPER_LOG), ...Object.values(PERMISSIONS.OPER_LOG),
...Object.values(PERMISSIONS.FILE), ...Object.values(PERMISSIONS.FILE),
...Object.values(PERMISSIONS.PICTURE), ...Object.values(PERMISSIONS.PICTURE),
...Object.values(PERMISSIONS.ONLINE),
], ],
} as const } as const

View File

@ -0,0 +1,33 @@
import { request } from '@/service/http'
import type {
OnlineUserCountVo,
OnlineUserSearchForm,
OnlineUserVo,
PageOnlineUserVo,
SysUserOnlineQueryBo,
} from './types'
// 重新导出类型定义
export type { OnlineUserCountVo, OnlineUserSearchForm, OnlineUserVo, PageOnlineUserVo, SysUserOnlineQueryBo }
/**
* 线
*/
export function getOnlineUserListPage(params: SysUserOnlineQueryBo) {
return request.Get<Service.ResponseResult<PageOnlineUserVo>>('/coder/sysUserOnline/listPage', { params })
}
/**
*
* @param userId ID
*/
export function logoutUser(userId: string) {
return request.Get<Service.ResponseResult<string>>(`/coder/sysUserOnline/logout/${userId}`)
}
/**
* 线
*/
export function getOnlineUserCount() {
return request.Get<Service.ResponseResult<OnlineUserCountVo>>('/coder/sysUserOnline/count')
}

View File

@ -0,0 +1,97 @@
/**
* 线 -
*/
/**
* 线
*/
export interface OnlineUserVo {
/** 用户ID */
userId: string
/** 登录名称 */
loginName: string
/** 用户名 */
userName: string
/** 用户头像 */
avatar?: string
/** 性别[1-男 2-女 3-未知] */
sex?: string
/** 手机号 */
phone?: string
/** 邮箱 */
email?: string
/** 用户类型[1-系统用户 2-注册用户 3-微信用户] */
userType?: string
/** 城市ID[可新增城市表-暂时无用] */
cityId?: string
/** 登录时间 */
loginTime?: string
/** 创建时间 */
createTime?: string
/** 登录IP */
loginIp?: string
/** 登录地址 */
loginAddress?: string
/** 浏览器类型 */
browser?: string
/** 操作系统 */
os?: string
/** 设备名字 */
deviceName?: string
/** 是否超级管理员 */
isCoderAdmin?: boolean
}
/**
* 线
*/
export interface SysUserOnlineQueryBo {
/** 页码 */
pageNo?: number
/** 页大小 */
pageSize?: number
/** 登录名称 */
loginName?: string
/** 用户名字 */
userName?: string
/** IP地址 */
loginIp?: string
}
/**
* 线
*/
export interface OnlineUserSearchForm {
/** 登录名称 */
loginName?: string
/** 用户名字 */
userName?: string
/** IP地址 */
loginIp?: string
}
/**
* 线
*/
export interface PageOnlineUserVo {
/** 总记录数 */
total: number
/** 当前页 */
current: number
/** 每页大小 */
size: number
/** 总页数 */
pages: number
/** 数据列表 */
records: OnlineUserVo[]
}
/**
* 线
*/
export interface OnlineUserCountVo {
/** 在线用户总数 */
onlineCount: number
/** 统计时间戳 */
timestamp: number
}

View File

@ -0,0 +1,688 @@
<template>
<div class="online-user-management p-1 bg-gray-50 h-screen flex flex-col">
<!-- 搜索表单和在线用户表格 -->
<div class="bg-white rounded-lg shadow-sm border border-gray-100 flex-1 flex flex-col overflow-hidden">
<!-- 搜索表单 -->
<div class="px-4 py-2 border-b border-gray-100">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
label-width="auto"
class="search-form"
>
<n-grid :cols="4" :x-gap="8" :y-gap="4">
<n-grid-item>
<n-form-item label="登录账号" path="loginName">
<n-input
v-model:value="searchForm.loginName"
placeholder="请输入登录账号"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="用户姓名" path="userName">
<n-input
v-model:value="searchForm.userName"
placeholder="请输入用户姓名"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="登录IP" path="loginIp">
<n-input
v-model:value="searchForm.loginIp"
placeholder="请输入登录IP"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="" path="">
<NSpace>
<NButton type="primary" @click="handleSearch">
<template #icon>
<NIcon><icon-park-outline:search /></NIcon>
</template>
搜索
</NButton>
<NButton @click="handleReset">
<template #icon>
<NIcon><IconParkOutlineRefresh /></NIcon>
</template>
重置
</NButton>
</NSpace>
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
<!-- 统计信息和操作栏 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<div class="flex items-center gap-4">
<!-- 在线用户统计 -->
<div class="flex items-center gap-2">
<NIcon size="16" color="#18a058">
<icon-park-outline:people />
</NIcon>
<span class="text-sm font-medium text-gray-700">在线用户:</span>
<NTag type="primary" size="small">
{{ onlineCount }}
</NTag>
</div>
<!-- 自动刷新开关 -->
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">自动刷新:</span>
<NSwitch
v-model:value="autoRefreshEnabled"
size="small"
@update:value="handleAutoRefreshToggle"
/>
<span v-if="autoRefreshEnabled" class="text-xs text-gray-500">
({{ refreshCountdown }}s)
</span>
</div>
<!-- 最后更新时间 -->
<div class="text-xs text-gray-500">
最后更新: {{ lastUpdateTime }}
</div>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span> {{ pagination.itemCount }} </span>
<NButton text @click="getOnlineUserList">
<template #icon>
<NIcon><IconParkOutlineRefresh /></NIcon>
</template>
</NButton>
</div>
</div>
<!-- 表格内容 -->
<div class="table-wrapper flex-1 overflow-auto pt-4">
<!-- 数据表格 -->
<n-data-table
v-if="tableData.length > 0 || loading"
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: OnlineUserVo) => row.userId"
:bordered="false"
:single-line="false"
:scroll-x="1650"
size="medium"
class="custom-table"
@update:checked-row-keys="handleRowSelectionChange"
/>
<!-- 空状态 -->
<CoiEmpty
v-else
:type="getEmptyType()"
:title="getEmptyTitle()"
:description="getEmptyDescription()"
:show-action="true"
:action-text="getEmptyActionText()"
size="medium"
@action="handleEmptyAction"
>
<template #action>
<NButton
type="primary"
size="medium"
@click="handleEmptyAction"
>
<template #icon>
<NIcon>
<IconParkOutlineRefresh v-if="hasSearchConditions()" />
<icon-park-outline:refresh v-else />
</NIcon>
</template>
{{ getEmptyActionText() }}
</NButton>
</template>
</CoiEmpty>
</div>
<!-- 分页器 -->
<div v-if="tableData.length > 0" class="border-t border-gray-100">
<CoiPagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:show-size-picker="true"
:page-sizes="[10, 20, 50, 100]"
:show-quick-jumper="true"
:show-current-info="true"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { h, onBeforeUnmount, onMounted, ref } from 'vue'
import type { DataTableColumns, FormInst } from 'naive-ui'
import { NButton, NIcon, NPopconfirm, NSpace, NSwitch, NTag } from 'naive-ui'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlineRefresh from '~icons/icon-park-outline/refresh'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import CoiPagination from '@/components/common/CoiPagination.vue'
import {
getOnlineUserCount,
getOnlineUserListPage,
logoutUser,
} from '@/service/api/monitor/online'
import type { OnlineUserSearchForm, OnlineUserVo } from '@/service/api/monitor/online'
import { coiMsgError, coiMsgSuccess } from '@/utils/coi'
import { PERMISSIONS } from '@/constants/permissions'
import { usePermission } from '@/hooks'
//
const { hasButton } = usePermission()
//
const loading = ref(false)
const tableData = ref<OnlineUserVo[]>([])
const searchFormRef = ref<FormInst | null>(null)
const onlineCount = ref(0)
const lastUpdateTime = ref('')
//
const autoRefreshEnabled = ref(false)
const refreshInterval = ref<NodeJS.Timeout | null>(null)
const refreshCountdown = ref(30)
const countdownInterval = ref<NodeJS.Timeout | null>(null)
//
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
})
//
const searchForm = ref<OnlineUserSearchForm>({})
//
const selectedRows = ref<OnlineUserVo[]>([])
//
const columns: DataTableColumns<OnlineUserVo> = [
{
type: 'selection',
width: 50,
},
{
title: '序号',
key: 'index',
width: 70,
align: 'center',
render: (_, index) => {
return (pagination.value.page - 1) * pagination.value.pageSize + index + 1
},
},
{
title: '头像',
key: 'avatar',
width: 80,
align: 'center',
render: (row) => {
return h('div', { class: 'flex justify-center' }, [
h('div', {
class: 'w-10 h-10 rounded-full overflow-hidden border-2 border-gray-200',
}, [
row.avatar
? h('img', {
src: row.avatar,
alt: `${row.userName}的头像`,
class: 'w-full h-full object-cover',
onError: (e) => {
//
(e.target as HTMLElement).style.display = 'none'
const parent = (e.target as HTMLElement).parentElement
if (parent) {
parent.className = 'w-10 h-10 rounded-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold shadow-md'
parent.textContent = row.loginName.charAt(0).toUpperCase()
}
},
})
: h('div', {
class: 'w-full h-full bg-gradient-to-r from-blue-400 to-purple-500 flex items-center justify-center text-white text-sm font-bold',
}, row.loginName.charAt(0).toUpperCase()),
]),
])
},
},
{
title: '登录账号',
key: 'loginName',
width: 120,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-600 font-medium' }, row.loginName)
},
},
{
title: '用户姓名',
key: 'userName',
width: 120,
align: 'center',
render: (row) => {
return h('div', { class: 'text-gray-600' }, row.userName)
},
},
{
title: '登录IP',
key: 'loginIp',
width: 130,
align: 'center',
render: (row) => {
return h('span', {
class: 'text-blue-600 font-mono text-sm bg-blue-50 px-2 py-1 rounded',
}, row.loginIp || '-')
},
},
{
title: '登录地址',
key: 'loginAddress',
width: 150,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600' }, row.loginAddress || '-')
},
},
{
title: '浏览器',
key: 'browser',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600 text-sm' }, row.browser || '-')
},
},
{
title: '操作系统',
key: 'os',
width: 120,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600 text-sm' }, row.os || '-')
},
},
{
title: '登录设备',
key: 'deviceName',
width: 130,
align: 'center',
ellipsis: { tooltip: true },
render: (row) => {
return h('span', { class: 'text-gray-600 text-sm' }, row.deviceName || '-')
},
},
{
title: '登录时间',
key: 'loginTime',
width: 160,
align: 'center',
render: (row) => {
return h('span', { class: 'text-gray-500 text-sm' }, row.loginTime || '-')
},
},
{
title: '操作',
key: 'actions',
width: 150,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
//
if (hasButton(PERMISSIONS.ONLINE.LOGOUT)) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleLogout(row),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => `确定要强制注销用户「${row.userName}」吗?`,
trigger: () => h(NButton, {
type: 'error',
secondary: true,
size: 'small',
class: 'action-btn-secondary action-btn-danger',
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineDelete),
}),
default: () => '强制注销',
}),
}))
}
return h('div', { class: 'flex items-center justify-center gap-2' }, buttons)
},
},
]
// 线
async function getOnlineUserList() {
loading.value = true
try {
//
const filteredParams = Object.entries(searchForm.value).reduce((acc, [key, value]) => {
if (value !== null && value !== undefined && value !== '') {
acc[key] = value
}
return acc
}, {} as Record<string, any>)
const params = {
pageNo: pagination.value.page,
pageSize: pagination.value.pageSize,
...filteredParams,
}
const { isSuccess, data } = await getOnlineUserListPage(params)
if (isSuccess && data) {
tableData.value = data.records || []
pagination.value.itemCount = data.total || 0
//
lastUpdateTime.value = new Date().toLocaleString()
}
}
catch (error) {
console.error('获取在线用户列表失败:', error)
coiMsgError('获取在线用户列表失败')
}
finally {
loading.value = false
}
}
// 线
async function getOnlineUserCountData() {
try {
const { isSuccess, data } = await getOnlineUserCount()
if (isSuccess && data) {
onlineCount.value = data.onlineCount
}
}
catch (error) {
console.error('获取在线用户统计失败:', error)
}
}
//
async function handleLogout(row: OnlineUserVo) {
try {
const { isSuccess } = await logoutUser(row.userId)
if (isSuccess) {
coiMsgSuccess(`用户「${row.userName}」已被强制注销`)
await Promise.all([getOnlineUserList(), getOnlineUserCountData()])
}
else {
coiMsgError('强制注销失败')
}
}
catch (error) {
console.error('强制注销失败:', error)
coiMsgError('强制注销失败')
}
}
//
async function handleSearch() {
pagination.value.page = 1
await getOnlineUserList()
}
//
async function handleReset() {
searchForm.value = {}
pagination.value.page = 1
await getOnlineUserList()
}
//
async function handlePageChange(page: number) {
pagination.value.page = page
await getOnlineUserList()
}
async function handlePageSizeChange(pageSize: number) {
pagination.value.pageSize = pageSize
pagination.value.page = 1
await getOnlineUserList()
}
//
function handleRowSelectionChange(rowKeys: (string | number)[]) {
selectedRows.value = tableData.value.filter(row => rowKeys.includes(row.userId))
}
//
function handleAutoRefreshToggle(enabled: boolean) {
if (enabled) {
startAutoRefresh()
}
else {
stopAutoRefresh()
}
}
function startAutoRefresh() {
stopAutoRefresh() //
refreshCountdown.value = 30
//
countdownInterval.value = setInterval(() => {
refreshCountdown.value--
if (refreshCountdown.value <= 0) {
refreshCountdown.value = 30
}
}, 1000)
//
refreshInterval.value = setInterval(async () => {
await Promise.all([getOnlineUserList(), getOnlineUserCountData()])
}, 30000)
}
function stopAutoRefresh() {
if (refreshInterval.value) {
clearInterval(refreshInterval.value)
refreshInterval.value = null
}
if (countdownInterval.value) {
clearInterval(countdownInterval.value)
countdownInterval.value = null
}
}
//
function getEmptyType() {
return hasSearchConditions() ? 'search' : 'empty'
}
function getEmptyTitle() {
return hasSearchConditions() ? '未找到相关数据' : '暂无在线用户'
}
function getEmptyDescription() {
return hasSearchConditions() ? '请尝试调整搜索条件' : '当前没有用户在线'
}
function getEmptyActionText() {
return hasSearchConditions() ? '重新搜索' : '刷新数据'
}
function hasSearchConditions() {
const { loginName, userName, loginIp } = searchForm.value
return !!(loginName || userName || loginIp)
}
async function handleEmptyAction() {
if (hasSearchConditions()) {
await handleReset()
}
else {
await getOnlineUserList()
}
}
//
onMounted(async () => {
await Promise.all([getOnlineUserList(), getOnlineUserCountData()])
})
onBeforeUnmount(() => {
stopAutoRefresh()
})
</script>
<style scoped>
/* 搜索表单样式 */
.search-form :deep(.n-form-item) {
margin-bottom: 8px !important;
}
.search-form :deep(.n-form-item .n-form-item-feedback-wrapper) {
min-height: 0 !important;
padding-top: 2px !important;
}
.search-form :deep(.n-form-item .n-form-item-label) {
padding-bottom: 2px !important;
}
.search-form :deep(.n-input),
.search-form :deep(.n-select) {
font-size: 14px !important;
}
.search-form :deep(.n-input .n-input__input-el) {
padding: 2px 1px !important;
min-height: 32px !important;
}
/* 表格样式 */
.custom-table :deep(.n-data-table-td) {
padding: 8px 12px;
vertical-align: middle;
}
.custom-table :deep(.n-data-table-th) {
background-color: #fafafa;
font-weight: 500;
padding: 12px;
border-bottom: 1px solid #e8e8e8;
}
.custom-table :deep(.n-data-table-tr:hover .n-data-table-td) {
background-color: #f8f9fa;
}
.custom-table :deep(.n-data-table-tbody .n-data-table-tr) {
border-bottom: 1px solid #f0f0f0;
}
.custom-table :deep(.n-button--small-type) {
height: 28px;
padding: 0 8px;
font-size: 12px;
}
.custom-table :deep(.n-tag--small-type) {
height: 22px;
font-size: 11px;
padding: 0 6px;
}
.table-wrapper {
border-radius: 8px;
}
.table-wrapper::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-wrapper::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.table-wrapper::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.table-wrapper::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.table-wrapper::-webkit-scrollbar-corner {
background: #f1f1f1;
}
.custom-table :deep(.n-data-table-base-table) {
border-radius: 8px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
.custom-table :deep(.n-data-table-base-table)::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 按钮样式 */
.action-btn-secondary {
border: 1px solid var(--border-color);
}
.action-btn-danger {
color: var(--error-color);
border-color: var(--error-color);
}
.action-btn-danger:hover {
background-color: var(--error-color);
color: white;
}
</style>