|
|
@@ -0,0 +1,379 @@
|
|
|
+import React, { useState } from 'react';
|
|
|
+import { useQuery } from '@tanstack/react-query';
|
|
|
+import { format } from 'date-fns';
|
|
|
+import { zhCN } from 'date-fns/locale';
|
|
|
+import { toast } from 'sonner';
|
|
|
+import { Search, Trash2, Eye, MessageSquare, Image, Settings } from 'lucide-react';
|
|
|
+
|
|
|
+import { chatMessageClient } from '@/client/api';
|
|
|
+import type { InferResponseType, InferRequestType } from 'hono/client';
|
|
|
+import { CreateChatMessageDto, UpdateChatMessageDto } from '@/server/modules/chat/chat-message.schema';
|
|
|
+
|
|
|
+import { Button } from '@/client/components/ui/button';
|
|
|
+import { Input } from '@/client/components/ui/input';
|
|
|
+import { Badge } from '@/client/components/ui/badge';
|
|
|
+import { Card, CardContent, CardHeader, CardTitle } from '@/client/components/ui/card';
|
|
|
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table';
|
|
|
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog';
|
|
|
+import { Skeleton } from '@/client/components/ui/skeleton';
|
|
|
+import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/client/components/ui/alert-dialog';
|
|
|
+
|
|
|
+import { DataTablePagination } from '@/client/admin/components/DataTablePagination';
|
|
|
+
|
|
|
+type ChatMessageListResponse = InferResponseType<typeof chatMessageClient.$get, 200>;
|
|
|
+type ChatMessageDetailResponse = InferResponseType<typeof chatMessageClient[':id']['$get'], 200>;
|
|
|
+type CreateChatMessageRequest = InferRequestType<typeof chatMessageClient.$post>['json'];
|
|
|
+type UpdateChatMessageRequest = InferRequestType<typeof chatMessageClient[':id']['$put']>['json'];
|
|
|
+
|
|
|
+// 消息类型映射
|
|
|
+const messageTypeMap = {
|
|
|
+ text: { label: '文本', color: 'bg-blue-100 text-blue-800', icon: MessageSquare },
|
|
|
+ image: { label: '图片', color: 'bg-green-100 text-green-800', icon: Image },
|
|
|
+ system: { label: '系统', color: 'bg-purple-100 text-purple-800', icon: Settings }
|
|
|
+};
|
|
|
+
|
|
|
+// 聊天消息管理页面
|
|
|
+export const ChatMessagesPage = () => {
|
|
|
+ const [searchParams, setSearchParams] = useState({
|
|
|
+ page: 1,
|
|
|
+ limit: 10,
|
|
|
+ search: ''
|
|
|
+ });
|
|
|
+ const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
|
|
+ const [selectedMessage, setSelectedMessage] = useState<any>(null);
|
|
|
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
|
+ const [messageToDelete, setMessageToDelete] = useState<number | null>(null);
|
|
|
+
|
|
|
+ // 获取聊天消息列表
|
|
|
+ const { data: messagesData, isLoading, refetch } = useQuery({
|
|
|
+ queryKey: ['chat-messages', searchParams],
|
|
|
+ queryFn: async () => {
|
|
|
+ const res = await chatMessageClient.$get({
|
|
|
+ query: {
|
|
|
+ page: searchParams.page,
|
|
|
+ pageSize: searchParams.limit,
|
|
|
+ keyword: searchParams.search
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (res.status !== 200) {
|
|
|
+ throw new Error('获取消息列表失败');
|
|
|
+ }
|
|
|
+ return await res.json();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const messages = messagesData?.data || [];
|
|
|
+ const totalCount = messagesData?.pagination?.total || 0;
|
|
|
+
|
|
|
+ // 处理搜索
|
|
|
+ const handleSearch = (e: React.FormEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ setSearchParams(prev => ({ ...prev, page: 1 }));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 打开消息详情对话框
|
|
|
+ const showDetailDialog = (message: any) => {
|
|
|
+ setSelectedMessage(message);
|
|
|
+ setDetailDialogOpen(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 打开删除确认对话框
|
|
|
+ const showDeleteDialog = (messageId: number) => {
|
|
|
+ setMessageToDelete(messageId);
|
|
|
+ setDeleteDialogOpen(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 处理删除消息
|
|
|
+ const handleDelete = async () => {
|
|
|
+ if (!messageToDelete) return;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await chatMessageClient[':id']['$delete']({
|
|
|
+ param: { id: messageToDelete.toString() }
|
|
|
+ });
|
|
|
+ if (res.status !== 204) {
|
|
|
+ throw new Error('删除消息失败');
|
|
|
+ }
|
|
|
+ toast.success('消息删除成功');
|
|
|
+ setDeleteDialogOpen(false);
|
|
|
+ setMessageToDelete(null);
|
|
|
+ refetch();
|
|
|
+ } catch (error) {
|
|
|
+ toast.error('删除失败,请重试');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 格式化时间戳
|
|
|
+ const formatTimestamp = (timestamp: number) => {
|
|
|
+ return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN });
|
|
|
+ };
|
|
|
+
|
|
|
+ // 截断长文本
|
|
|
+ const truncateText = (text: string, maxLength: number = 50) => {
|
|
|
+ if (text.length <= maxLength) return text;
|
|
|
+ return text.substring(0, maxLength) + '...';
|
|
|
+ };
|
|
|
+
|
|
|
+ // 加载状态
|
|
|
+ if (isLoading) {
|
|
|
+ return (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <Skeleton className="h-8 w-48" />
|
|
|
+ <Skeleton className="h-10 w-32" />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <Skeleton className="h-6 w-1/4" />
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <div className="space-y-3">
|
|
|
+ {[...Array(5)].map((_, i) => (
|
|
|
+ <div key={i} className="flex gap-4">
|
|
|
+ <Skeleton className="h-10 flex-1" />
|
|
|
+ <Skeleton className="h-10 flex-1" />
|
|
|
+ <Skeleton className="h-10 flex-1" />
|
|
|
+ <Skeleton className="h-10 w-20" />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-4">
|
|
|
+ {/* 页面标题 */}
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <h1 className="text-2xl font-bold">聊天消息管理</h1>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 搜索区域 */}
|
|
|
+ <Card>
|
|
|
+ <CardHeader>
|
|
|
+ <CardTitle>消息列表</CardTitle>
|
|
|
+ </CardHeader>
|
|
|
+ <CardContent>
|
|
|
+ <form onSubmit={handleSearch} className="flex gap-2 mb-4">
|
|
|
+ <div className="relative flex-1 max-w-sm">
|
|
|
+ <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
|
+ <Input
|
|
|
+ placeholder="搜索消息内容、课堂ID、发送者..."
|
|
|
+ value={searchParams.search}
|
|
|
+ onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
|
|
|
+ className="pl-8"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <Button type="submit" variant="outline">
|
|
|
+ 搜索
|
|
|
+ </Button>
|
|
|
+ </form>
|
|
|
+
|
|
|
+ {/* 消息表格 */}
|
|
|
+ <div className="rounded-md border">
|
|
|
+ <Table>
|
|
|
+ <TableHeader>
|
|
|
+ <TableRow>
|
|
|
+ <TableHead>ID</TableHead>
|
|
|
+ <TableHead>课堂ID</TableHead>
|
|
|
+ <TableHead>消息类型</TableHead>
|
|
|
+ <TableHead>消息内容</TableHead>
|
|
|
+ <TableHead>发送者</TableHead>
|
|
|
+ <TableHead>发送时间</TableHead>
|
|
|
+ <TableHead>创建时间</TableHead>
|
|
|
+ <TableHead className="text-right">操作</TableHead>
|
|
|
+ </TableRow>
|
|
|
+ </TableHeader>
|
|
|
+ <TableBody>
|
|
|
+ {messages.map((message) => {
|
|
|
+ const MessageTypeIcon = messageTypeMap[message.type]?.icon || MessageSquare;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <TableRow key={message.id}>
|
|
|
+ <TableCell className="font-medium">{message.id}</TableCell>
|
|
|
+ <TableCell className="font-mono text-sm">{message.classId}</TableCell>
|
|
|
+ <TableCell>
|
|
|
+ <Badge
|
|
|
+ variant="outline"
|
|
|
+ className={messageTypeMap[message.type]?.color}
|
|
|
+ >
|
|
|
+ <MessageTypeIcon className="mr-1 h-3 w-3" />
|
|
|
+ {messageTypeMap[message.type]?.label || message.type}
|
|
|
+ </Badge>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell title={message.content}>
|
|
|
+ <div className="max-w-xs truncate">
|
|
|
+ {truncateText(message.content)}
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ <div className="text-sm">
|
|
|
+ <div>{message.senderName || '未知用户'}</div>
|
|
|
+ <div className="text-muted-foreground text-xs">
|
|
|
+ {message.senderId ? `ID: ${message.senderId}` : '系统消息'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {message.timestamp ? formatTimestamp(message.timestamp) : '-'}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell>
|
|
|
+ {message.createdAt ? format(new Date(message.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN }) : '-'}
|
|
|
+ </TableCell>
|
|
|
+ <TableCell className="text-right">
|
|
|
+ <div className="flex justify-end gap-2">
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => showDetailDialog(message)}
|
|
|
+ title="查看详情"
|
|
|
+ >
|
|
|
+ <Eye className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ variant="ghost"
|
|
|
+ size="sm"
|
|
|
+ onClick={() => showDeleteDialog(message.id)}
|
|
|
+ className="text-destructive hover:text-destructive"
|
|
|
+ title="删除消息"
|
|
|
+ >
|
|
|
+ <Trash2 className="h-4 w-4" />
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </TableCell>
|
|
|
+ </TableRow>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </TableBody>
|
|
|
+ </Table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {messages.length === 0 && (
|
|
|
+ <div className="text-center py-8">
|
|
|
+ <p className="text-muted-foreground">暂无消息数据</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 分页 */}
|
|
|
+ <DataTablePagination
|
|
|
+ currentPage={searchParams.page}
|
|
|
+ pageSize={searchParams.limit}
|
|
|
+ totalCount={totalCount}
|
|
|
+ onPageChange={(page, limit) => setSearchParams(prev => ({ ...prev, page, limit }))}
|
|
|
+ />
|
|
|
+ </CardContent>
|
|
|
+ </Card>
|
|
|
+
|
|
|
+ {/* 消息详情对话框 */}
|
|
|
+ <Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
|
|
|
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
|
|
+ <DialogHeader>
|
|
|
+ <DialogTitle>消息详情</DialogTitle>
|
|
|
+ <DialogDescription>
|
|
|
+ 查看聊天消息的详细信息
|
|
|
+ </DialogDescription>
|
|
|
+ </DialogHeader>
|
|
|
+
|
|
|
+ {selectedMessage && (
|
|
|
+ <div className="space-y-4">
|
|
|
+ <div className="grid grid-cols-2 gap-4">
|
|
|
+ <div>
|
|
|
+ <label className="text-sm font-medium text-muted-foreground">消息ID</label>
|
|
|
+ <p className="text-sm">{selectedMessage.id}</p>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-sm font-medium text-muted-foreground">课堂ID</label>
|
|
|
+ <p className="text-sm font-mono">{selectedMessage.classId}</p>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-sm font-medium text-muted-foreground">消息类型</label>
|
|
|
+ <div className="flex items-center gap-1">
|
|
|
+ {(() => {
|
|
|
+ const IconComponent = messageTypeMap[selectedMessage.type]?.icon || MessageSquare;
|
|
|
+ return <IconComponent className="h-4 w-4" />;
|
|
|
+ })()}
|
|
|
+ <span>{messageTypeMap[selectedMessage.type]?.label || selectedMessage.type}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-sm font-medium text-muted-foreground">发送者</label>
|
|
|
+ <p className="text-sm">
|
|
|
+ {selectedMessage.senderName || '未知用户'}
|
|
|
+ {selectedMessage.senderId && (
|
|
|
+ <span className="text-muted-foreground text-xs block">
|
|
|
+ ID: {selectedMessage.senderId}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-sm font-medium text-muted-foreground">发送时间</label>
|
|
|
+ <p className="text-sm">
|
|
|
+ {selectedMessage.timestamp ? formatTimestamp(selectedMessage.timestamp) : '-'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-sm font-medium text-muted-foreground">创建时间</label>
|
|
|
+ <p className="text-sm">
|
|
|
+ {selectedMessage.createdAt ? format(new Date(selectedMessage.createdAt), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN }) : '-'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="text-sm font-medium text-muted-foreground">消息内容</label>
|
|
|
+ <div className="mt-1 p-3 bg-muted rounded-md">
|
|
|
+ {selectedMessage.type === 'image' ? (
|
|
|
+ <div className="text-sm text-muted-foreground">
|
|
|
+ 图片消息(内容可能包含图片URL或文件ID)
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <p className="text-sm whitespace-pre-wrap break-words">
|
|
|
+ {selectedMessage.content}
|
|
|
+ </p>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {selectedMessage.type === 'image' && (
|
|
|
+ <div>
|
|
|
+ <label className="text-sm font-medium text-muted-foreground">图片信息</label>
|
|
|
+ <div className="mt-1 p-3 bg-muted rounded-md">
|
|
|
+ <p className="text-sm text-muted-foreground">
|
|
|
+ 这是一个图片消息,可能需要特殊处理来显示图片内容
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <DialogFooter>
|
|
|
+ <Button onClick={() => setDetailDialogOpen(false)}>关闭</Button>
|
|
|
+ </DialogFooter>
|
|
|
+ </DialogContent>
|
|
|
+ </Dialog>
|
|
|
+
|
|
|
+ {/* 删除确认对话框 */}
|
|
|
+ <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
|
+ <AlertDialogContent>
|
|
|
+ <AlertDialogHeader>
|
|
|
+ <AlertDialogTitle>确认删除</AlertDialogTitle>
|
|
|
+ <AlertDialogDescription>
|
|
|
+ 确定要删除这条聊天消息吗?此操作无法撤销,消息将被永久删除。
|
|
|
+ </AlertDialogDescription>
|
|
|
+ </AlertDialogHeader>
|
|
|
+ <AlertDialogFooter>
|
|
|
+ <AlertDialogCancel>取消</AlertDialogCancel>
|
|
|
+ <AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
|
|
+ 删除
|
|
|
+ </AlertDialogAction>
|
|
|
+ </AlertDialogFooter>
|
|
|
+ </AlertDialogContent>
|
|
|
+ </AlertDialog>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|