ConversationList.tsx 2.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. "use client";
  2. import { useCallback, useEffect, useRef } from "react";
  3. import { Loader2 } from "lucide-react";
  4. import { ConversationSummary } from "@/features/agent/types";
  5. import { ConversationListItem } from "./ConversationListItem";
  6. import { Button } from "@/components/ui/button";
  7. interface ConversationListProps {
  8. conversations: ConversationSummary[];
  9. selectedConversationId: number | null;
  10. onSelect: (id: number) => void;
  11. searchQuery: string;
  12. hasMore?: boolean;
  13. loadingMore?: boolean;
  14. onLoadMore?: () => void;
  15. }
  16. export function ConversationList({
  17. conversations,
  18. selectedConversationId,
  19. onSelect,
  20. searchQuery,
  21. hasMore = false,
  22. loadingMore = false,
  23. onLoadMore,
  24. }: ConversationListProps) {
  25. const scrollRootRef = useRef<HTMLDivElement>(null);
  26. const sentinelRef = useRef<HTMLDivElement>(null);
  27. const handleLoadMore = useCallback(() => {
  28. if (!hasMore || loadingMore || searchQuery.trim() || !onLoadMore) {
  29. return;
  30. }
  31. onLoadMore();
  32. }, [hasMore, loadingMore, onLoadMore, searchQuery]);
  33. useEffect(() => {
  34. const root = scrollRootRef.current;
  35. const sentinel = sentinelRef.current;
  36. if (!root || !sentinel || !onLoadMore || searchQuery.trim()) {
  37. return;
  38. }
  39. const observer = new IntersectionObserver(
  40. (entries) => {
  41. if (entries.some((entry) => entry.isIntersecting)) {
  42. handleLoadMore();
  43. }
  44. },
  45. { root, rootMargin: "120px", threshold: 0 }
  46. );
  47. observer.observe(sentinel);
  48. return () => observer.disconnect();
  49. }, [handleLoadMore, onLoadMore, searchQuery, conversations.length]);
  50. if (conversations.length === 0) {
  51. return (
  52. <div className="flex-1 overflow-y-auto scrollbar-auto">
  53. <div className="text-center text-muted-foreground mt-8 text-sm">
  54. {searchQuery ? "未找到匹配的对话" : "暂无对话"}
  55. </div>
  56. </div>
  57. );
  58. }
  59. return (
  60. <div
  61. ref={scrollRootRef}
  62. className="flex-1 overflow-y-auto px-2 py-2 scrollbar-auto min-h-0"
  63. >
  64. {conversations.map((conversation) => (
  65. <ConversationListItem
  66. key={conversation.id}
  67. conversation={conversation}
  68. selected={selectedConversationId === conversation.id}
  69. onSelect={onSelect}
  70. />
  71. ))}
  72. {!searchQuery.trim() && hasMore ? (
  73. <div ref={sentinelRef} className="py-3 flex justify-center">
  74. {loadingMore ? (
  75. <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
  76. ) : (
  77. <Button variant="ghost" size="sm" onClick={handleLoadMore}>
  78. 加载更多
  79. </Button>
  80. )}
  81. </div>
  82. ) : null}
  83. </div>
  84. );
  85. }