重构消息通知弹窗
This commit is contained in:
parent
8e2d39107f
commit
58ad4b2643
@ -238,6 +238,12 @@ const EditDialog: React.FC<EditDialogProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<IconSelect
|
||||||
|
visible={iconSelectVisible}
|
||||||
|
onCancel={() => setIconSelectVisible(false)}
|
||||||
|
value={formData.icon}
|
||||||
|
onChange={(value) => setFormData({ ...formData, icon: value })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@ -293,17 +299,6 @@ const EditDialog: React.FC<EditDialogProps> = ({
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 图标选择器 */}
|
|
||||||
<IconSelect
|
|
||||||
visible={iconSelectVisible}
|
|
||||||
onCancel={() => setIconSelectVisible(false)}
|
|
||||||
value={formData.icon}
|
|
||||||
onChange={(value) => {
|
|
||||||
setFormData({ ...formData, icon: value });
|
|
||||||
setIconSelectVisible(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
326
frontend/src/pages/System/Online/List/index.tsx
Normal file
326
frontend/src/pages/System/Online/List/index.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { Search, RefreshCw, Users, TrendingUp, Clock, LogOut } from 'lucide-react';
|
||||||
|
import { getOnlineUsers, getOnlineStatistics, kickUser } from './service';
|
||||||
|
import type { OnlineUserResponse, OnlineStatistics } from './types';
|
||||||
|
import type { Page } from '@/types/base';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const OnlineUserList: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [data, setData] = useState<Page<OnlineUserResponse> | null>(null);
|
||||||
|
const [statistics, setStatistics] = useState<OnlineStatistics | null>(null);
|
||||||
|
const [keyword, setKeyword] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize] = useState(10);
|
||||||
|
const [kickDialogOpen, setKickDialogOpen] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<OnlineUserResponse | null>(null);
|
||||||
|
|
||||||
|
// 加载在线用户列表
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getOnlineUsers({
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
pageNum: currentPage,
|
||||||
|
pageSize,
|
||||||
|
sortField: 'loginTime',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
});
|
||||||
|
setData(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '加载失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载统计数据
|
||||||
|
const loadStatistics = async () => {
|
||||||
|
try {
|
||||||
|
const stats = await getOnlineStatistics();
|
||||||
|
setStatistics(stats);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载统计失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
loadStatistics();
|
||||||
|
}, [currentPage, pageSize]);
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新
|
||||||
|
const handleRefresh = () => {
|
||||||
|
loadData();
|
||||||
|
loadStatistics();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开强制下线确认对话框
|
||||||
|
const handleKickClick = (user: OnlineUserResponse) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setKickDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 确认强制下线
|
||||||
|
const handleKickConfirm = async () => {
|
||||||
|
if (!selectedUser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await kickUser(selectedUser.userId);
|
||||||
|
toast({
|
||||||
|
title: '操作成功',
|
||||||
|
description: `用户 "${selectedUser.username}" 已被强制下线`,
|
||||||
|
});
|
||||||
|
setKickDialogOpen(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
loadData();
|
||||||
|
loadStatistics();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('强制下线失败:', error);
|
||||||
|
toast({
|
||||||
|
title: '操作失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化在线时长
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}小时${minutes}分钟`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}分钟`;
|
||||||
|
} else {
|
||||||
|
return `${seconds}秒`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (time: string): string => {
|
||||||
|
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">当前在线</CardTitle>
|
||||||
|
<Users className="h-4 w-4 text-blue-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-blue-600">
|
||||||
|
{statistics?.totalOnline || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">实时在线用户数</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">今日峰值</CardTitle>
|
||||||
|
<TrendingUp className="h-4 w-4 text-orange-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
|
{statistics?.todayPeak || 0}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">今日最高在线人数</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">平均时长</CardTitle>
|
||||||
|
<Clock className="h-4 w-4 text-green-600" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold text-green-600">
|
||||||
|
{statistics?.averageOnlineTime || '-'}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">平均在线时长</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主内容卡片 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle>在线用户列表</CardTitle>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名/昵称"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => setKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="w-64"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch} size="sm">
|
||||||
|
<Search className="h-4 w-4 mr-1" />
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleRefresh} variant="outline" size="sm">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[120px]">用户名</TableHead>
|
||||||
|
<TableHead className="w-[120px]">昵称</TableHead>
|
||||||
|
<TableHead className="w-[150px]">部门</TableHead>
|
||||||
|
<TableHead className="w-[180px]">登录时间</TableHead>
|
||||||
|
<TableHead className="w-[120px]">在线时长</TableHead>
|
||||||
|
<TableHead className="w-[180px]">最后活跃</TableHead>
|
||||||
|
<TableHead className="w-[140px]">IP地址</TableHead>
|
||||||
|
<TableHead className="w-[120px]">浏览器</TableHead>
|
||||||
|
<TableHead className="w-[120px]">操作系统</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
|
||||||
|
加载中...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.content && data.content.length > 0 ? (
|
||||||
|
data.content.map((user) => (
|
||||||
|
<TableRow key={user.userId}>
|
||||||
|
<TableCell className="font-medium">{user.username}</TableCell>
|
||||||
|
<TableCell>{user.nickname}</TableCell>
|
||||||
|
<TableCell>{user.departmentName || '-'}</TableCell>
|
||||||
|
<TableCell>{formatTime(user.loginTime)}</TableCell>
|
||||||
|
<TableCell>{formatDuration(user.onlineDuration)}</TableCell>
|
||||||
|
<TableCell>{formatTime(user.lastActiveTime)}</TableCell>
|
||||||
|
<TableCell>{user.ipAddress || '-'}</TableCell>
|
||||||
|
<TableCell>{user.browser || '-'}</TableCell>
|
||||||
|
<TableCell>{user.os || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleKickClick(user)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-1" />
|
||||||
|
强制下线
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{data && data.totalElements > 0 && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
共 {data.totalElements} 条记录,第 {currentPage} / {data.totalPages} 页
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
|
disabled={currentPage >= data.totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 强制下线确认对话框 */}
|
||||||
|
<AlertDialog open={kickDialogOpen} onOpenChange={setKickDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认强制下线</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要强制用户 <span className="font-semibold text-foreground">"{selectedUser?.username}"</span> 下线吗?
|
||||||
|
<br />
|
||||||
|
该操作将立即终止用户的会话。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleKickConfirm} className="bg-red-600 hover:bg-red-700">
|
||||||
|
确认下线
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OnlineUserList;
|
||||||
17
frontend/src/pages/System/Online/List/service.ts
Normal file
17
frontend/src/pages/System/Online/List/service.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { OnlineUserResponse, OnlineUserQuery, OnlineStatistics } from './types';
|
||||||
|
import { Page } from '@/types/base';
|
||||||
|
|
||||||
|
const BASE_URL = '/api/v1/online';
|
||||||
|
|
||||||
|
// 获取在线用户列表(分页)
|
||||||
|
export const getOnlineUsers = (params?: OnlineUserQuery) =>
|
||||||
|
request.get<Page<OnlineUserResponse>>(`${BASE_URL}/users`, { params });
|
||||||
|
|
||||||
|
// 获取在线统计
|
||||||
|
export const getOnlineStatistics = () =>
|
||||||
|
request.get<OnlineStatistics>(`${BASE_URL}/statistics`);
|
||||||
|
|
||||||
|
// 强制用户下线
|
||||||
|
export const kickUser = (userId: number) =>
|
||||||
|
request.delete(`${BASE_URL}/kick/${userId}`);
|
||||||
31
frontend/src/pages/System/Online/List/types.ts
Normal file
31
frontend/src/pages/System/Online/List/types.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { BaseResponse } from '@/types/base';
|
||||||
|
|
||||||
|
// 在线用户查询参数
|
||||||
|
export interface OnlineUserQuery {
|
||||||
|
keyword?: string; // 搜索关键词(用户名/昵称)
|
||||||
|
pageNum?: number; // 页码,从1开始
|
||||||
|
pageSize?: number; // 每页大小
|
||||||
|
sortField?: string; // 排序字段
|
||||||
|
sortOrder?: string; // 排序方向
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线用户响应
|
||||||
|
export interface OnlineUserResponse extends BaseResponse {
|
||||||
|
userId: number; // 用户ID
|
||||||
|
username: string; // 用户名
|
||||||
|
nickname: string; // 昵称
|
||||||
|
departmentName: string; // 部门名称
|
||||||
|
loginTime: string; // 登录时间
|
||||||
|
lastActiveTime: string; // 最后活跃时间
|
||||||
|
onlineDuration: number; // 在线时长(秒)
|
||||||
|
ipAddress: string; // IP地址
|
||||||
|
browser: string; // 浏览器
|
||||||
|
os: string; // 操作系统
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线统计
|
||||||
|
export interface OnlineStatistics {
|
||||||
|
totalOnline: number; // 当前在线人数
|
||||||
|
todayPeak: number; // 今日峰值
|
||||||
|
averageOnlineTime: string; // 平均在线时长
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user