|
@@ -1,9 +1,14 @@
|
|
|
"use client";
|
|
"use client";
|
|
|
|
|
|
|
|
-import { useEffect, useRef } from "react";
|
|
|
|
|
|
|
+import { useEffect, useRef, useState } from "react";
|
|
|
import { MessageItem } from "@/features/agent/types";
|
|
import { MessageItem } from "@/features/agent/types";
|
|
|
import { formatMessageTime } from "@/utils/format";
|
|
import { formatMessageTime } from "@/utils/format";
|
|
|
import { highlightText } from "@/utils/highlight";
|
|
import { highlightText } from "@/utils/highlight";
|
|
|
|
|
+import { Badge } from "@/components/ui/badge";
|
|
|
|
|
+import { Button } from "@/components/ui/button";
|
|
|
|
|
+import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
|
|
|
+import { Paperclip, Download, X } from "lucide-react";
|
|
|
|
|
+import { API_BASE_URL } from "@/lib/config";
|
|
|
|
|
|
|
|
interface MessageListProps {
|
|
interface MessageListProps {
|
|
|
messages: MessageItem[];
|
|
messages: MessageItem[];
|
|
@@ -34,6 +39,10 @@ export function MessageList({
|
|
|
const lastMarkedReadRef = useRef<number>(0);
|
|
const lastMarkedReadRef = useRef<number>(0);
|
|
|
const lastMessageIdRef = useRef<number | null>(null);
|
|
const lastMessageIdRef = useRef<number | null>(null);
|
|
|
const lastMessageCountRef = useRef<number>(0);
|
|
const lastMessageCountRef = useRef<number>(0);
|
|
|
|
|
+ const hasInitialScrolledRef = useRef(false); // 标记是否已经完成初始滚动
|
|
|
|
|
+ // 图片预览状态(必须在所有条件返回之前声明)
|
|
|
|
|
+ const [imagePreviewOpen, setImagePreviewOpen] = useState(false);
|
|
|
|
|
+ const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (conversationId !== lastConversationIdRef.current) {
|
|
if (conversationId !== lastConversationIdRef.current) {
|
|
@@ -41,6 +50,7 @@ export function MessageList({
|
|
|
shouldStickToBottomRef.current = true;
|
|
shouldStickToBottomRef.current = true;
|
|
|
lastMessageIdRef.current = null;
|
|
lastMessageIdRef.current = null;
|
|
|
lastMessageCountRef.current = 0;
|
|
lastMessageCountRef.current = 0;
|
|
|
|
|
+ hasInitialScrolledRef.current = false; // 重置初始滚动标记
|
|
|
}
|
|
}
|
|
|
}, [conversationId]);
|
|
}, [conversationId]);
|
|
|
|
|
|
|
@@ -134,45 +144,80 @@ export function MessageList({
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 在 DOM 更新后检查当前位置
|
|
|
|
|
- const { scrollTop, scrollHeight, clientHeight } = currentContainer;
|
|
|
|
|
- const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
|
|
|
|
- const isNearBottom = distanceToBottom < 100;
|
|
|
|
|
- // 更新 shouldStickToBottomRef,确保使用最新的位置信息
|
|
|
|
|
- shouldStickToBottomRef.current = isNearBottom;
|
|
|
|
|
|
|
+ // 对于新消息,需要延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
|
|
|
|
|
+ // 使用双重 requestAnimationFrame + 小延迟,给图片加载留出时间
|
|
|
|
|
+ const checkAndScroll = () => {
|
|
|
|
|
+ const container = containerRef.current;
|
|
|
|
|
+ if (!container) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 在 DOM 更新后检查当前位置
|
|
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = container;
|
|
|
|
|
+ const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
|
|
|
|
+ const isNearBottom = distanceToBottom < 100;
|
|
|
|
|
+ // 更新 shouldStickToBottomRef,确保使用最新的位置信息
|
|
|
|
|
+ shouldStickToBottomRef.current = isNearBottom;
|
|
|
|
|
+
|
|
|
|
|
+ // 检查是否是初始加载(首次加载消息或切换对话后首次加载)
|
|
|
|
|
+ const isInitialLoad = !hasInitialScrolledRef.current && messages.length > 0;
|
|
|
|
|
+
|
|
|
|
|
+ // 滚动逻辑:
|
|
|
|
|
+ // 1. 如果是初始加载(首次加载消息或切换对话),无论什么情况都自动滚动到底部
|
|
|
|
|
+ // 2. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true)
|
|
|
|
|
+ // 3. 如果最后一条消息是对方发送的:
|
|
|
|
|
+ // - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
|
|
|
|
|
+ // - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
|
|
|
|
|
+ // - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
|
|
|
|
|
+ // 4. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
|
|
|
|
|
+ // 这样确保访客端和客服端的行为一致:初始加载时显示最新消息,当用户在底部附近时,收到新消息会自动滚动到底部
|
|
|
|
|
+ const shouldAutoScroll =
|
|
|
|
|
+ isInitialLoad ||
|
|
|
|
|
+ (hasNewMessage &&
|
|
|
|
|
+ (isLastMessageFromCurrentUser || isNearBottom));
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ if (keyword) {
|
|
|
|
|
+ const keywordLower = keyword.toLowerCase();
|
|
|
|
|
+ const matchingMessage = messages.find((message) =>
|
|
|
|
|
+ message.content.toLowerCase().includes(keywordLower)
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- // 滚动逻辑:
|
|
|
|
|
- // 1. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true)
|
|
|
|
|
- // 2. 如果最后一条消息是对方发送的:
|
|
|
|
|
- // - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
|
|
|
|
|
- // - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
|
|
|
|
|
- // - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
|
|
|
|
|
- // 3. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
|
|
|
|
|
- // 这样确保访客端和客服端的行为一致:当用户在底部附近时,收到新消息会自动滚动到底部
|
|
|
|
|
- const shouldAutoScroll =
|
|
|
|
|
- hasNewMessage &&
|
|
|
|
|
- (isLastMessageFromCurrentUser || isNearBottom);
|
|
|
|
|
-
|
|
|
|
|
- if (keyword) {
|
|
|
|
|
- const keywordLower = keyword.toLowerCase();
|
|
|
|
|
- const matchingMessage = messages.find((message) =>
|
|
|
|
|
- message.content.toLowerCase().includes(keywordLower)
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- if (matchingMessage) {
|
|
|
|
|
- const scroll = () => {
|
|
|
|
|
- const target = messageRefs.current[matchingMessage.id];
|
|
|
|
|
- if (target) {
|
|
|
|
|
- target.scrollIntoView({
|
|
|
|
|
- behavior: "smooth",
|
|
|
|
|
- block: "center",
|
|
|
|
|
- inline: "nearest",
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ if (matchingMessage) {
|
|
|
|
|
+ const scroll = () => {
|
|
|
|
|
+ const target = messageRefs.current[matchingMessage.id];
|
|
|
|
|
+ if (target) {
|
|
|
|
|
+ target.scrollIntoView({
|
|
|
|
|
+ behavior: "smooth",
|
|
|
|
|
+ block: "center",
|
|
|
|
|
+ inline: "nearest",
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ setTimeout(onHighlightClear, 3000);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ setTimeout(scroll, 200);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ if (!shouldAutoScroll) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const scrollBottom = () => {
|
|
|
|
|
+ const container = containerRef.current;
|
|
|
|
|
+ if (!container) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ container.scrollTo({
|
|
|
|
|
+ top: container.scrollHeight,
|
|
|
|
|
+ behavior: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
|
|
|
|
|
+ });
|
|
|
|
|
+ // 标记初始滚动已完成
|
|
|
|
|
+ if (isInitialLoad) {
|
|
|
|
|
+ hasInitialScrolledRef.current = true;
|
|
|
}
|
|
}
|
|
|
- setTimeout(onHighlightClear, 3000);
|
|
|
|
|
};
|
|
};
|
|
|
-
|
|
|
|
|
- setTimeout(scroll, 200);
|
|
|
|
|
|
|
+ setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
|
|
|
|
|
+ onHighlightClear();
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
if (!shouldAutoScroll) {
|
|
if (!shouldAutoScroll) {
|
|
|
return;
|
|
return;
|
|
@@ -182,62 +227,87 @@ export function MessageList({
|
|
|
if (!container) {
|
|
if (!container) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // 如果 scrollHeight === clientHeight,说明没有滚动条,强制设置高度
|
|
|
|
|
+ // 这通常发生在 flex 布局中,子元素高度没有正确限制时
|
|
|
|
|
+ if (container.scrollHeight === container.clientHeight && container.parentElement) {
|
|
|
|
|
+ const parent = container.parentElement;
|
|
|
|
|
+ const parentHeight = parent.offsetHeight;
|
|
|
|
|
+ container.style.height = `${parentHeight}px`;
|
|
|
|
|
+ container.style.maxHeight = `${parentHeight}px`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
container.scrollTo({
|
|
container.scrollTo({
|
|
|
top: container.scrollHeight,
|
|
top: container.scrollHeight,
|
|
|
- behavior: "smooth",
|
|
|
|
|
|
|
+ behavior: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
|
|
|
});
|
|
});
|
|
|
|
|
+ // 标记初始滚动已完成
|
|
|
|
|
+ if (isInitialLoad) {
|
|
|
|
|
+ hasInitialScrolledRef.current = true;
|
|
|
|
|
+ }
|
|
|
};
|
|
};
|
|
|
- setTimeout(scrollBottom, 100);
|
|
|
|
|
- onHighlightClear();
|
|
|
|
|
- }
|
|
|
|
|
- } else {
|
|
|
|
|
- if (!shouldAutoScroll) {
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
|
|
|
}
|
|
}
|
|
|
- const scrollBottom = () => {
|
|
|
|
|
- const container = containerRef.current;
|
|
|
|
|
- if (!container) {
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- container.scrollTo({
|
|
|
|
|
- top: container.scrollHeight,
|
|
|
|
|
- behavior: "smooth",
|
|
|
|
|
- });
|
|
|
|
|
- };
|
|
|
|
|
- setTimeout(scrollBottom, 100);
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- // 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
|
|
|
|
|
- // 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
|
|
|
|
|
- if (conversationId && onMarkMessagesRead && messages.length > 0) {
|
|
|
|
|
- // 延迟标记为已读,确保滚动动画完成
|
|
|
|
|
- if (markReadTimerRef.current) {
|
|
|
|
|
- clearTimeout(markReadTimerRef.current);
|
|
|
|
|
- }
|
|
|
|
|
- markReadTimerRef.current = setTimeout(() => {
|
|
|
|
|
- // 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
|
|
|
|
|
- const shouldMarkRead = shouldAutoScroll || isNearBottom;
|
|
|
|
|
- if (!shouldMarkRead) {
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ // 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
|
|
|
|
|
+ // 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
|
|
|
|
|
+ if (conversationId && onMarkMessagesRead && messages.length > 0) {
|
|
|
|
|
+ // 延迟标记为已读,确保滚动动画完成
|
|
|
|
|
+ if (markReadTimerRef.current) {
|
|
|
|
|
+ clearTimeout(markReadTimerRef.current);
|
|
|
}
|
|
}
|
|
|
|
|
+ markReadTimerRef.current = setTimeout(() => {
|
|
|
|
|
+ // 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
|
|
|
|
|
+ const shouldMarkRead = shouldAutoScroll || isNearBottom;
|
|
|
|
|
+ if (!shouldMarkRead) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- const unreadMessages = messages.filter((msg) => {
|
|
|
|
|
- const isFromOther = currentUserIsAgent
|
|
|
|
|
- ? !msg.sender_is_agent
|
|
|
|
|
- : msg.sender_is_agent;
|
|
|
|
|
- return isFromOther && !msg.is_read;
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const unreadMessages = messages.filter((msg) => {
|
|
|
|
|
+ const isFromOther = currentUserIsAgent
|
|
|
|
|
+ ? !msg.sender_is_agent
|
|
|
|
|
+ : msg.sender_is_agent;
|
|
|
|
|
+ return isFromOther && !msg.is_read;
|
|
|
|
|
+ });
|
|
|
|
|
|
|
|
- if (unreadMessages.length > 0) {
|
|
|
|
|
- // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
|
|
|
|
|
- const now = Date.now();
|
|
|
|
|
- if (now - lastMarkedReadRef.current < 2000) {
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ if (unreadMessages.length > 0) {
|
|
|
|
|
+ // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
|
|
|
|
|
+ const now = Date.now();
|
|
|
|
|
+ if (now - lastMarkedReadRef.current < 2000) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ onMarkMessagesRead(conversationId, currentUserIsAgent);
|
|
|
|
|
+ lastMarkedReadRef.current = now;
|
|
|
}
|
|
}
|
|
|
- onMarkMessagesRead(conversationId, currentUserIsAgent);
|
|
|
|
|
- lastMarkedReadRef.current = now;
|
|
|
|
|
- }
|
|
|
|
|
- }, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
|
|
|
|
|
|
|
+ }, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 对于新消息,延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
|
|
|
|
|
+ if (hasNewMessage) {
|
|
|
|
|
+ // 检查最后一条消息是否包含图片/文件
|
|
|
|
|
+ const lastMessageHasFile = lastMessage.file_url;
|
|
|
|
|
+
|
|
|
|
|
+ if (lastMessageHasFile) {
|
|
|
|
|
+ // 如果包含文件,延迟更长时间,确保图片加载完成
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ checkAndScroll();
|
|
|
|
|
+ }, 200); // 给图片加载留出更多时间
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 普通消息,正常延迟
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ checkAndScroll();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 非新消息(如状态更新),直接检查
|
|
|
|
|
+ checkAndScroll();
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
}, [
|
|
}, [
|
|
@@ -252,27 +322,52 @@ export function MessageList({
|
|
|
|
|
|
|
|
if (loading) {
|
|
if (loading) {
|
|
|
return (
|
|
return (
|
|
|
- <div className="flex-1 flex items-center justify-center bg-gray-50">
|
|
|
|
|
- <span className="text-sm text-gray-500">消息加载中...</span>
|
|
|
|
|
|
|
+ <div className="flex-1 flex items-center justify-center bg-muted/30">
|
|
|
|
|
+ <span className="text-sm text-muted-foreground">消息加载中...</span>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
if (messages.length === 0) {
|
|
if (messages.length === 0) {
|
|
|
return (
|
|
return (
|
|
|
- <div ref={containerRef} className="flex-1 overflow-y-auto p-4 bg-gray-50">
|
|
|
|
|
- <div className="text-center text-gray-400 mt-8 text-sm">暂无消息</div>
|
|
|
|
|
|
|
+ <div ref={containerRef} className="flex-1 min-h-0 overflow-y-auto p-4 bg-muted/30 scrollbar-auto">
|
|
|
|
|
+ <div className="text-center text-muted-foreground mt-8 text-sm">暂无消息</div>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
- <div
|
|
|
|
|
- ref={containerRef}
|
|
|
|
|
- className="flex-1 overflow-y-auto p-4 bg-gray-50"
|
|
|
|
|
- >
|
|
|
|
|
- <div className="space-y-4">
|
|
|
|
|
- {messages.map((message) => {
|
|
|
|
|
|
|
+ <>
|
|
|
|
|
+ {/* 图片预览对话框 */}
|
|
|
|
|
+ <Dialog open={imagePreviewOpen} onOpenChange={setImagePreviewOpen}>
|
|
|
|
|
+ <DialogContent className="max-w-4xl max-h-[90vh] p-0">
|
|
|
|
|
+ {previewImageUrl && (
|
|
|
|
|
+ <div className="relative">
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ className="absolute top-2 right-2 z-10"
|
|
|
|
|
+ onClick={() => setImagePreviewOpen(false)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <X className="w-4 h-4" />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={previewImageUrl}
|
|
|
|
|
+ alt="预览"
|
|
|
|
|
+ className="w-full h-auto max-h-[90vh] object-contain"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </DialogContent>
|
|
|
|
|
+ </Dialog>
|
|
|
|
|
+
|
|
|
|
|
+ <div
|
|
|
|
|
+ ref={containerRef}
|
|
|
|
|
+ className="h-full w-full overflow-y-auto p-4 bg-muted/30 scrollbar-auto"
|
|
|
|
|
+ style={{ height: '100%' }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="space-y-4">
|
|
|
|
|
+ {messages.map((message) => {
|
|
|
const keyword = highlightKeyword.trim();
|
|
const keyword = highlightKeyword.trim();
|
|
|
const isMatching =
|
|
const isMatching =
|
|
|
keyword !== "" &&
|
|
keyword !== "" &&
|
|
@@ -289,33 +384,66 @@ export function MessageList({
|
|
|
ref={(element) => {
|
|
ref={(element) => {
|
|
|
messageRefs.current[message.id] = element;
|
|
messageRefs.current[message.id] = element;
|
|
|
}}
|
|
}}
|
|
|
- className={`text-center text-xs text-gray-500`}
|
|
|
|
|
|
|
+ className={`text-center text-xs text-muted-foreground`}
|
|
|
>
|
|
>
|
|
|
- <span className="inline-block px-3 py-1 rounded-full bg-gray-200 text-gray-700">
|
|
|
|
|
|
|
+ <Badge variant="secondary" className="inline-block">
|
|
|
{message.content}
|
|
{message.content}
|
|
|
- </span>
|
|
|
|
|
|
|
+ </Badge>
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const isSenderAgent = message.sender_is_agent;
|
|
|
|
|
|
|
+ // 确保 sender_is_agent 是布尔值
|
|
|
|
|
+ const isSenderAgent = Boolean(message.sender_is_agent);
|
|
|
const isCurrentUser = currentUserIsAgent
|
|
const isCurrentUser = currentUserIsAgent
|
|
|
? isSenderAgent
|
|
? isSenderAgent
|
|
|
: !isSenderAgent;
|
|
: !isSenderAgent;
|
|
|
const alignment = isCurrentUser ? "justify-end" : "justify-start";
|
|
const alignment = isCurrentUser ? "justify-end" : "justify-start";
|
|
|
const bubbleColor = isCurrentUser
|
|
const bubbleColor = isCurrentUser
|
|
|
- ? "bg-blue-500 text-white"
|
|
|
|
|
- : "bg-white text-gray-800 border border-gray-200";
|
|
|
|
|
|
|
+ ? "bg-primary text-primary-foreground shadow-md"
|
|
|
|
|
+ : "bg-card text-card-foreground border border-border/50 shadow-sm";
|
|
|
const cornerClass = isCurrentUser ? "rounded-br-none" : "rounded-bl-none";
|
|
const cornerClass = isCurrentUser ? "rounded-br-none" : "rounded-bl-none";
|
|
|
- const receiptClass = isCurrentUser
|
|
|
|
|
- ? message.is_read
|
|
|
|
|
- ? currentUserIsAgent
|
|
|
|
|
- ? "text-blue-400"
|
|
|
|
|
- : "text-blue-200"
|
|
|
|
|
- : currentUserIsAgent
|
|
|
|
|
- ? ""
|
|
|
|
|
- : "text-blue-200"
|
|
|
|
|
- : "";
|
|
|
|
|
|
|
+ // 计算已读回执的样式类名
|
|
|
|
|
+ // 统一使用相同的样式:蓝色半透明(text-primary/70)
|
|
|
|
|
+ // 因为访客端和客服端的当前用户消息都是蓝色背景(bg-primary),所以使用相同的样式
|
|
|
|
|
+ const receiptClass = isCurrentUser ? "text-primary/70" : "";
|
|
|
|
|
+
|
|
|
|
|
+ // 文件相关
|
|
|
|
|
+ const hasFile = Boolean(message.file_url);
|
|
|
|
|
+ const isImage = message.file_type === "image";
|
|
|
|
|
+ const isDocument = message.file_type === "document";
|
|
|
|
|
+
|
|
|
|
|
+ // 获取文件URL(完整URL)
|
|
|
|
|
+ const getFileUrl = (fileUrl: string | null | undefined): string => {
|
|
|
|
|
+ if (!fileUrl) return "";
|
|
|
|
|
+ if (fileUrl.startsWith("http")) return fileUrl;
|
|
|
|
|
+ return `${API_BASE_URL}${fileUrl}`;
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 格式化文件大小
|
|
|
|
|
+ const formatFileSize = (bytes: number | null | undefined): string => {
|
|
|
|
|
+ if (!bytes) return "";
|
|
|
|
|
+ if (bytes < 1024) return bytes + " B";
|
|
|
|
|
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
|
|
|
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 打开图片预览
|
|
|
|
|
+ const handleImageClick = (url: string) => {
|
|
|
|
|
+ setPreviewImageUrl(url);
|
|
|
|
|
+ setImagePreviewOpen(true);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 下载文件
|
|
|
|
|
+ const handleDownload = (url: string, fileName: string | null | undefined) => {
|
|
|
|
|
+ const link = document.createElement("a");
|
|
|
|
|
+ link.href = url;
|
|
|
|
|
+ link.download = fileName || "file";
|
|
|
|
|
+ link.target = "_blank";
|
|
|
|
|
+ document.body.appendChild(link);
|
|
|
|
|
+ link.click();
|
|
|
|
|
+ document.body.removeChild(link);
|
|
|
|
|
+ };
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
@@ -327,15 +455,66 @@ export function MessageList({
|
|
|
>
|
|
>
|
|
|
<div className="max-w-[70%]">
|
|
<div className="max-w-[70%]">
|
|
|
<div
|
|
<div
|
|
|
- className={`px-4 py-2 rounded-2xl shadow-sm ${
|
|
|
|
|
|
|
+ className={`px-4 py-2.5 rounded-2xl ${
|
|
|
cornerClass
|
|
cornerClass
|
|
|
- } ${bubbleColor}`}
|
|
|
|
|
|
|
+ } ${bubbleColor} transition-shadow hover:shadow-md`}
|
|
|
>
|
|
>
|
|
|
- <div className="whitespace-pre-wrap break-words text-sm">
|
|
|
|
|
- {bubbleContent}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ {/* 文本内容 */}
|
|
|
|
|
+ {message.content && (
|
|
|
|
|
+ <div className="whitespace-pre-wrap break-words text-sm">
|
|
|
|
|
+ {bubbleContent}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* 文件显示 */}
|
|
|
|
|
+ {hasFile && message.file_url && (
|
|
|
|
|
+ <div className={message.content ? "mt-2" : ""}>
|
|
|
|
|
+ {isImage ? (
|
|
|
|
|
+ // 图片预览
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="cursor-pointer rounded-lg overflow-hidden max-w-[300px] border border-border/30 hover:border-primary/50 transition-colors shadow-sm"
|
|
|
|
|
+ onClick={() => handleImageClick(getFileUrl(message.file_url))}
|
|
|
|
|
+ >
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={getFileUrl(message.file_url)}
|
|
|
|
|
+ alt={message.file_name || "图片"}
|
|
|
|
|
+ className="max-w-full h-auto"
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : isDocument ? (
|
|
|
|
|
+ // 文档显示
|
|
|
|
|
+ <div className="flex items-center gap-2 p-3 bg-background/60 rounded-lg border border-border/30 hover:bg-background/80 transition-colors">
|
|
|
|
|
+ <Paperclip className="w-4 h-4 flex-shrink-0" />
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="text-sm font-medium truncate">
|
|
|
|
|
+ {message.file_name || "文件"}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {message.file_size && (
|
|
|
|
|
+ <div className="text-xs text-muted-foreground">
|
|
|
|
|
+ {formatFileSize(message.file_size)}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ variant="ghost"
|
|
|
|
|
+ size="sm"
|
|
|
|
|
+ onClick={() =>
|
|
|
|
|
+ handleDownload(
|
|
|
|
|
+ getFileUrl(message.file_url),
|
|
|
|
|
+ message.file_name
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ className="flex-shrink-0"
|
|
|
|
|
+ >
|
|
|
|
|
+ <Download className="w-4 h-4" />
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
</div>
|
|
</div>
|
|
|
- <div className="flex items-center gap-1 mt-1 text-[10px] text-gray-400">
|
|
|
|
|
|
|
+ <div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground">
|
|
|
{isCurrentUser && (
|
|
{isCurrentUser && (
|
|
|
<span className={receiptClass}>
|
|
<span className={receiptClass}>
|
|
|
{message.is_read ? "✓✓" : "✓"}
|
|
{message.is_read ? "✓✓" : "✓"}
|
|
@@ -347,8 +526,9 @@ export function MessageList({
|
|
|
</div>
|
|
</div>
|
|
|
);
|
|
);
|
|
|
})}
|
|
})}
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
- </div>
|
|
|
|
|
|
|
+ </>
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|