重构消息通知弹窗
This commit is contained in:
parent
8e2d39107f
commit
58ad4b2643
@ -238,6 +238,12 @@ const EditDialog: React.FC<EditDialogProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<IconSelect
|
||||
visible={iconSelectVisible}
|
||||
onCancel={() => setIconSelectVisible(false)}
|
||||
value={formData.icon}
|
||||
onChange={(value) => setFormData({ ...formData, icon: value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
@ -293,17 +299,6 @@ const EditDialog: React.FC<EditDialogProps> = ({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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