Explorar o código

✨ feat(admin): 添加聊天消息管理功能

- 在管理菜单中添加聊天消息管理入口
- 创建聊天消息管理页面,支持列表展示、搜索和分页
- 实现消息详情查看和删除功能
- 添加消息类型区分和格式化展示

✨ feat(admin): 完善聊天消息管理路由配置

- 添加聊天消息管理页面路由配置
yourname hai 6 meses
pai
achega
0253c0ae97

+ 9 - 1
src/client/admin/menu.tsx

@@ -12,7 +12,8 @@ import {
   LineChart,
   LineChart,
   Code,
   Code,
   Calendar,
   Calendar,
-  File
+  File,
+  MessageSquare
 } from 'lucide-react';
 } from 'lucide-react';
 
 
 export interface MenuItem {
 export interface MenuItem {
@@ -132,6 +133,13 @@ export const useMenu = () => {
       icon: <Calendar className="h-4 w-4" />,
       icon: <Calendar className="h-4 w-4" />,
       path: '/admin/date-notes',
       path: '/admin/date-notes',
       permission: 'stock:manage'
       permission: 'stock:manage'
+    },
+    {
+      key: 'chat-messages',
+      label: '聊天消息管理',
+      icon: <MessageSquare className="h-4 w-4" />,
+      path: '/admin/chat-messages',
+      permission: 'chat:manage'
     }
     }
   ];
   ];
 
 

+ 379 - 0
src/client/admin/pages/ChatMessages.tsx

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

+ 6 - 0
src/client/admin/routes.tsx

@@ -13,6 +13,7 @@ import { StockXunlianCodesPage } from './pages/StockXunlianCodesPage';
 import { DateNotesPage } from './pages/DateNotesPage';
 import { DateNotesPage } from './pages/DateNotesPage';
 import { LoginPage } from './pages/Login';
 import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
 import { FilesPage } from './pages/Files';
+import { ChatMessagesPage } from './pages/ChatMessages';
 
 
 export const router = createBrowserRouter([
 export const router = createBrowserRouter([
   {
   {
@@ -75,6 +76,11 @@ export const router = createBrowserRouter([
         element: <DateNotesPage />,
         element: <DateNotesPage />,
         errorElement: <ErrorPage />
         errorElement: <ErrorPage />
       },
       },
+      {
+        path: 'chat-messages',
+        element: <ChatMessagesPage />,
+        errorElement: <ErrorPage />
+      },
       {
       {
         path: '*',
         path: '*',
         element: <NotFoundPage />,
         element: <NotFoundPage />,