import { useState } from 'react' import { Check, CheckCheck, Clock, AlertCircle, Copy, Reply, Forward, StickyNote, Star, ListTodo, BookOpen, MoreHorizontal, Languages, Globe, ChevronDown, ChevronUp, Download, FileText, LoaderCircle, } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import type { Message } from '@/types' import { cn } from '@/utils/cn' import { toast } from '@/app/store/toast' import { formatTime } from '@/utils/format' import { languageName } from '@/config/languages' import { Dropdown } from '@/components/ui/dropdown' import { Avatar } from '@/components/ui/avatar' import { getMessageAttachment } from '@/services/api/conversations' export type ViewMode = 'original' | 'translated' | 'bilingual' function EmailAttachmentLink({ conversationId, messageId, attachment, }: { conversationId: string messageId: string attachment: NonNullable[number] }) { const { data, isLoading, error } = useQuery({ queryKey: ['email-attachment', conversationId, messageId, attachment.id], queryFn: () => getMessageAttachment(conversationId, messageId, attachment.id), enabled: attachment.available, staleTime: Infinity, retry: 1, }) const size = attachment.size >= 1024 * 1024 ? `${(attachment.size / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(attachment.size / 1024))} KB` return ( { if (!data?.dataUrl) event.preventDefault() }} className={cn( 'mt-2 flex items-center gap-2 rounded-xl border border-slate-200/80 bg-white/75 px-3 py-2 transition-colors', data?.dataUrl && 'hover:border-sky-200 hover:bg-sky-50', )} > {isLoading ? : } {attachment.name} {error ? '附件暂不可用' : size} {data?.dataUrl && } ) } export function MessageBubble({ msg, viewMode, onQuote, onMarkImportant, onConvertToTask, onAddToKnowledge, onAddNote, customerName, customerAvatar, }: { msg: Message viewMode: ViewMode onQuote: () => void onMarkImportant: () => void onConvertToTask: () => void onAddToKnowledge: () => void onAddNote: () => void customerName?: string customerAvatar?: string }) { const [showActions, setShowActions] = useState(false) const [translationExpanded, setTranslationExpanded] = useState(false) const isMe = msg.sender === 'agent' const isSystem = msg.sender === 'system' const rawTranslation = String(msg.translatedText || '') const translationCorrupted = /(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) || rawTranslation.length > (msg.originalText.length <= 20 ? 160 : Math.max(500, msg.originalText.length * 8)) const translatedText = translationCorrupted ? '' : rawTranslation const hasTranslation = translatedText && translatedText !== msg.originalText const outgoingChineseNote = isMe ? String( msg.translationCn || (hasTranslation && /[\u3400-\u9fff]/u.test(msg.originalText) ? msg.originalText : ''), ).trim() : '' const outgoingSentText = isMe && translatedText ? translatedText : msg.originalText const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6 const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang)) const sourceLanguage = hasKnownLanguage ? languageName(msg.originalLang) : '原文' const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({ queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId], queryFn: () => getMessageAttachment(msg.conversationId, msg.id), enabled: Boolean(msg.telegramFileId), staleTime: Infinity, retry: 1, }) if (isSystem) { return (
{msg.originalText}
) } const translationContent = (muted = true) => (

{translatedText}

{longTranslation && ( )}
) const renderContent = () => { if (msg.type !== 'text') { const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' } return msg.originalText ? (

{msg.originalText}

) : (
{labels[msg.type]}
) } if (!String(msg.originalText || '').trim()) { return

Telegram 服务通知

} if ( (viewMode === 'bilingual' || viewMode === 'translated') && msg.sender === 'customer' && msg.type === 'text' && (!translatedText || translationCorrupted) ) { return (

{msg.originalText}

{translationCorrupted ? '检测到异常译文,系统正在自动修复' : msg.translationStatus === 'failed' ? 'AI 翻译暂时失败,将自动重试' : 'AI 正在翻译…'}
) } if (isMe && viewMode === 'bilingual') { return (
客户收到 · {translatedText ? '英文译文' : '原文'}

{outgoingSentText}

{outgoingChineseNote ? (
中文备注 · 仅内部可见

{outgoingChineseNote}

) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? (
{msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'}
) : null}
) } if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) { return (
中文备注 · 仅内部可见

{outgoingChineseNote}

) } if (viewMode === 'bilingual' && hasTranslation) { return (
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}

{msg.originalText}

{isMe ? '发送内容 · 英文' : 'AI 译文 · 中文'}
{translationContent()}
) } if (viewMode === 'translated' && translatedText) { return (
{isMe ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`}
{translationContent(false)}
) } return (
{hasKnownLanguage && ( {sourceLanguage} )}

{msg.originalText}

) } const statusIcon = () => { switch (msg.status) { case 'sending': return case 'sent': return case 'delivered': return case 'read': return case 'failed': return default: return null } } const moreActions = [ { key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } }, { key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote }, { key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote }, { key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant }, { key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask }, { key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge }, ] return (
setShowActions(true)} onMouseLeave={() => setShowActions(false)} > {!isMe && ( )}
{!isMe && (

{msg.senderName || customerName}

)} {/* 消息气泡 */}
{msg.replyToId && (

回复消息

{msg.replyPreview || `消息 #${msg.replyToId}`}

)} {renderContent()} {msg.telegramFileId && attachmentLoading && (
正在读取 Telegram 文件…
)} {attachment && msg.type === 'image' && ( {attachment.name} )} {attachment && msg.type === 'audio' && (
{/* 底栏:时间 + 状态 */}
{formatTime(msg.createdAt)} {isMe && statusIcon()} {msg.isImportant && } {msg.internalNote && }
{/* 悬停操作按钮 */}
{showActions && ( } items={moreActions} /> )}
) }