Files
lingxi-web/src/components/inbox/MessageBubble.tsx
T

419 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Message['emailAttachments']>[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 (
<a
href={data?.dataUrl}
download={data?.name || attachment.name}
onClick={(event) => {
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 ? <LoaderCircle className="h-5 w-5 animate-spin text-sky-500" /> : <FileText className="h-5 w-5 text-sky-500" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-slate-700">{attachment.name}</span>
<span className="text-[10px] text-slate-400">{error ? '附件暂不可用' : size}</span>
</span>
{data?.dataUrl && <Download className="h-4 w-4 text-slate-400" />}
</a>
)
}
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 (
<div className="flex justify-center py-2">
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{msg.originalText}</span>
</div>
)
}
const translationContent = (muted = true) => (
<div>
<p
className={cn(
'whitespace-pre-wrap break-words text-sm leading-6',
muted && 'text-foreground/75',
longTranslation && !translationExpanded && 'max-h-28 overflow-hidden',
)}
>
{translatedText}
</p>
{longTranslation && (
<button
type="button"
onClick={() => setTranslationExpanded((value) => !value)}
className="mt-1.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-primary hover:bg-primary-50"
>
{translationExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{translationExpanded ? '收起译文' : '展开完整译文'}
</button>
)}
</div>
)
const renderContent = () => {
if (msg.type !== 'text') {
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
return msg.originalText ? (
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{msg.originalText}</p>
) : (
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-slate-500">
<span className="h-1.5 w-1.5 rounded-full bg-primary-400" />
{labels[msg.type]}
</div>
)
}
if (!String(msg.originalText || '').trim()) {
return <p className="text-xs text-slate-500">Telegram 服务通知</p>
}
if (
(viewMode === 'bilingual' || viewMode === 'translated') &&
msg.sender === 'customer' &&
msg.type === 'text' &&
(!translatedText || translationCorrupted)
) {
return (
<div className="space-y-2.5">
<p className="whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
<div className="flex items-center gap-1.5 border-t border-border/60 pt-2 text-xs text-muted-foreground">
<Languages className="h-3.5 w-3.5 text-primary" />
{translationCorrupted
? '检测到异常译文,系统正在自动修复'
: msg.translationStatus === 'failed'
? 'AI 翻译暂时失败,将自动重试'
: 'AI 正在翻译…'}
</div>
</div>
)
}
if (isMe && viewMode === 'bilingual') {
return (
<div className="space-y-2.5">
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100/80 px-2 py-0.5 text-[10px] font-medium text-blue-700">
<Globe className="h-3 w-3" />
客户收到 · {translatedText ? '英文译文' : '原文'}
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
</div>
{outgoingChineseNote ? (
<div data-testid="outgoing-chinese-note" className="border-t border-blue-200/60 pt-2">
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
<Languages className="h-3 w-3 text-primary" />
中文备注 · 仅内部可见
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
{outgoingChineseNote}
</p>
</div>
) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? (
<div className="flex items-center gap-1.5 border-t border-blue-200/60 pt-2 text-xs text-slate-500">
<Languages className="h-3.5 w-3.5 text-primary" />
{msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'}
</div>
) : null}
</div>
)
}
if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) {
return (
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
<Languages className="h-3 w-3 text-primary" />
中文备注 · 仅内部可见
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
{outgoingChineseNote}
</p>
</div>
)
}
if (viewMode === 'bilingual' && hasTranslation) {
return (
<div className="space-y-2.5">
<div>
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
</div>
<div className="border-t border-border/60 pt-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
<Languages className="h-3 w-3" />
{isMe ? '发送内容 · 英文' : 'AI 译文 · 中文'}
</span>
<div className="mt-1.5">{translationContent()}</div>
</div>
</div>
)
}
if (viewMode === 'translated' && translatedText) {
return (
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
<Languages className="h-3 w-3" />
{isMe ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`}
</span>
<div className="mt-1.5">{translationContent(false)}</div>
</div>
)
}
return (
<div>
{hasKnownLanguage && (
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{sourceLanguage}
</span>
)}
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
{msg.originalText}
</p>
</div>
)
}
const statusIcon = () => {
switch (msg.status) {
case 'sending':
return <Clock className="h-3 w-3 text-muted-foreground animate-pulse" />
case 'sent':
return <Check className="h-3 w-3 text-muted-foreground" />
case 'delivered':
return <CheckCheck className="h-3 w-3 text-muted-foreground" />
case 'read':
return <CheckCheck className="h-3 w-3 text-primary" />
case 'failed':
return <AlertCircle className="h-3 w-3 text-red-500" />
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 (
<div
data-testid="message-bubble"
data-sender={msg.sender}
className={cn(
'group flex gap-2 px-3 py-2 will-change-transform sm:px-4 sm:py-2.5',
isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
)}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
{!isMe && (
<Avatar
name={customerName || msg.senderName || 'Telegram 用户'}
src={customerAvatar}
size="xs"
className="order-1 mt-1"
/>
)}
<div className={cn('min-w-0 max-w-[min(84%,760px)] sm:max-w-[min(74%,760px)]', isMe ? 'order-2' : 'order-1')}>
{!isMe && (
<p className="mb-1 px-1 text-[11px] font-medium text-muted-foreground">
{msg.senderName || customerName}
</p>
)}
{/* 消息气泡 */}
<div
data-testid="message-surface"
data-direction={isMe ? 'outgoing' : 'incoming'}
className={cn(
'relative isolate rounded-2xl px-3.5 py-2.5 transition-shadow',
isMe
? 'telegram-bubble-out rounded-br-[5px]'
: 'telegram-bubble-in rounded-bl-[5px]',
msg.isImportant && 'ring-1 ring-amber-400',
)}
>
{msg.replyToId && (
<div className="mb-2 rounded-lg border-s-2 border-primary/70 bg-muted/60 px-2.5 py-1.5">
<p className="text-[10px] font-medium text-primary">回复消息</p>
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{msg.replyPreview || `消息 #${msg.replyToId}`}
</p>
</div>
)}
{renderContent()}
{msg.telegramFileId && attachmentLoading && (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-muted/70 px-3 py-2 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 Telegram 文件…
</div>
)}
{attachment && msg.type === 'image' && (
<a href={attachment.dataUrl} download={attachment.name} className="mt-2 block overflow-hidden rounded-xl">
<img src={attachment.dataUrl} alt={attachment.name} className="max-h-80 w-auto max-w-full object-contain" />
</a>
)}
{attachment && msg.type === 'audio' && (
<audio controls preload="metadata" src={attachment.dataUrl} className="mt-2 h-10 max-w-full" />
)}
{attachment && msg.type === 'file' && attachment.mimeType === 'image/webp' && (
<img src={attachment.dataUrl} alt={msg.stickerEmoji || attachment.name} className="mt-2 h-32 w-32 object-contain" />
)}
{attachment && msg.type === 'file' && attachment.mimeType === 'video/webm' && (
<video src={attachment.dataUrl} autoPlay loop muted playsInline className="mt-2 h-32 w-32 object-contain" />
)}
{attachment && msg.type === 'file' && !['image/webp', 'video/webm'].includes(attachment.mimeType) && (
<a
href={attachment.dataUrl}
download={attachment.name}
className="mt-2 flex items-center gap-2 rounded-xl border border-border bg-muted/50 px-3 py-2 hover:bg-muted"
>
<FileText className="h-5 w-5 text-primary" />
<span className="min-w-0 flex-1 truncate text-xs">{attachment.name}</span>
<Download className="h-4 w-4 text-muted-foreground" />
</a>
)}
{msg.telegramFileId && attachmentError && (
<p className="mt-2 rounded-lg bg-red-50 px-2.5 py-1.5 text-xs text-red-600">文件暂时无法下载,请稍后重试</p>
)}
{msg.emailAttachments?.map((emailAttachment) => (
<EmailAttachmentLink
key={emailAttachment.id}
conversationId={msg.conversationId}
messageId={msg.id}
attachment={emailAttachment}
/>
))}
</div>
{/* 底栏:时间 + 状态 */}
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}>
<span>{formatTime(msg.createdAt)}</span>
{isMe && statusIcon()}
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />}
{msg.internalNote && <StickyNote className="h-2.5 w-2.5 text-blue-500" />}
</div>
</div>
{/* 悬停操作按钮 */}
<div className={cn('flex items-center', isMe ? 'order-1' : 'order-2')}>
{showActions && (
<Dropdown
trigger={
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted">
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</button>
}
items={moreActions}
/>
)}
</div>
</div>
)
}