重构消息通知弹窗

This commit is contained in:
dengqichen 2025-11-20 16:32:33 +08:00
parent 8e2d39107f
commit 58ad4b2643
4 changed files with 380 additions and 11 deletions

View File

@ -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);
}}
/>
</> </>
); );
}; };

View 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;

View 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}`);

View 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; // 平均在线时长
}