af49d22ca7
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
504 lines
20 KiB
TypeScript
504 lines
20 KiB
TypeScript
import { useState } from 'react'
|
||
import {
|
||
Check,
|
||
CheckCheck,
|
||
Clock,
|
||
AlertCircle,
|
||
Copy,
|
||
Reply,
|
||
Forward,
|
||
StickyNote,
|
||
Star,
|
||
ListTodo,
|
||
BookOpen,
|
||
MoreHorizontal,
|
||
Languages,
|
||
Globe,
|
||
ChevronDown,
|
||
ChevronUp,
|
||
Download,
|
||
FileText,
|
||
LoaderCircle,
|
||
SmilePlus,
|
||
Trash2,
|
||
Brain,
|
||
} from 'lucide-react'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import type { ChannelType, 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,
|
||
onReact,
|
||
onDelete,
|
||
customerName,
|
||
customerAvatar,
|
||
channel,
|
||
showIntent = true,
|
||
}: {
|
||
msg: Message
|
||
viewMode: ViewMode
|
||
onQuote: () => void
|
||
onMarkImportant: () => void
|
||
onConvertToTask: () => void
|
||
onAddToKnowledge: () => void
|
||
onAddNote: () => void
|
||
onReact?: (emoji: string) => void
|
||
onDelete?: () => void
|
||
customerName?: string
|
||
customerAvatar?: string
|
||
channel?: ChannelType
|
||
showIntent?: boolean
|
||
}) {
|
||
const [showActions, setShowActions] = useState(false)
|
||
const [translationExpanded, setTranslationExpanded] = useState(false)
|
||
const isMe = msg.sender === 'agent'
|
||
const isSystem = msg.sender === 'system'
|
||
const originalText = String(msg.originalText || '')
|
||
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 > (originalText.length <= 20 ? 160 : Math.max(500, originalText.length * 8))
|
||
const translatedText = translationCorrupted ? '' : rawTranslation
|
||
const hasTranslation = translatedText && translatedText !== originalText
|
||
const outgoingChineseNote = isMe
|
||
? String(
|
||
msg.translationCn ||
|
||
(hasTranslation && /[\u3400-\u9fff]/u.test(originalText) ? originalText : ''),
|
||
).trim()
|
||
: ''
|
||
const outgoingSentText = isMe && translatedText ? translatedText : 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 targetLanguage = msg.targetLang && !['und', 'auto', 'unknown'].includes(msg.targetLang)
|
||
? languageName(msg.targetLang)
|
||
: '客户语言'
|
||
const providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram'
|
||
const contextualTranslation = msg.translationMethod === 'ai-contextual'
|
||
&& Number(msg.translationContextMessages || 0) > 0
|
||
const translationTrace = contextualTranslation
|
||
? `AI 结合最近 ${msg.translationContextMessages} 条会话消息${msg.translationConversationSummaryApplied ? '及客户画像摘要' : ''}完成翻译`
|
||
: 'AI 直接翻译当前消息'
|
||
const isMediaMessage = ['image', 'audio', 'file'].includes(msg.type)
|
||
const hasProviderAttachment = Boolean(
|
||
msg.telegramFileId
|
||
|| (msg.whatsappMessageId && isMediaMessage),
|
||
)
|
||
const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({
|
||
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId, msg.whatsappMessageId],
|
||
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
|
||
enabled: hasProviderAttachment,
|
||
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">{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.isDeleted) {
|
||
return (
|
||
<p className="flex items-center gap-1.5 text-xs italic text-slate-400">
|
||
<AlertCircle className="h-3.5 w-3.5" />
|
||
该消息已撤回
|
||
</p>
|
||
)
|
||
}
|
||
if (msg.type !== 'text') {
|
||
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
|
||
return originalText ? (
|
||
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{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 (!originalText.trim()) {
|
||
return <p className="text-xs text-slate-500">{providerName} 服务通知</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">{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 ? targetLanguage : '原文'}
|
||
</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">{originalText}</p>
|
||
</div>
|
||
<div className="border-t border-border/60 pt-2">
|
||
<span
|
||
title={translationTrace}
|
||
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 ? `发送内容 · ${targetLanguage}` : contextualTranslation ? 'AI 语境译文 · 中文' : 'AI 译文 · 中文'}
|
||
</span>
|
||
<div className="mt-1.5">{translationContent()}</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
if (viewMode === 'translated' && translatedText) {
|
||
return (
|
||
<div>
|
||
<span
|
||
title={translationTrace}
|
||
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} → ${targetLanguage}` : `${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')}>
|
||
{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: () => { void navigator.clipboard.writeText(originalText); toast.success('已复制') } },
|
||
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
|
||
...(channel === 'whatsapp' && onReact
|
||
? [{ key: 'react', label: '👍 回应', icon: SmilePlus, onSelect: () => onReact('👍') }]
|
||
: []),
|
||
{ 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 },
|
||
...(channel === 'whatsapp' && isMe && onDelete
|
||
? [{ key: 'delete', label: '撤回消息', icon: Trash2, danger: true, onSelect: onDelete }]
|
||
: []),
|
||
]
|
||
|
||
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 || `${providerName} 用户`}
|
||
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()}
|
||
{!isMe && showIntent && msg.intentAnalysis && (
|
||
<div
|
||
data-testid="message-intent"
|
||
title={msg.intentAnalysis.rationale}
|
||
className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-slate-200/70 pt-2"
|
||
>
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold text-violet-700">
|
||
<Brain className="h-3 w-3" />
|
||
意图 · {msg.intentAnalysis.label}
|
||
</span>
|
||
<span className="text-[10px] text-slate-400">
|
||
可信度 {Math.round(Number(msg.intentAnalysis.confidence || 0) * 100)}%
|
||
</span>
|
||
{['high', 'urgent'].includes(msg.intentAnalysis.urgency) && (
|
||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
||
{msg.intentAnalysis.urgency === 'urgent' ? '紧急' : '高优先级'}
|
||
</span>
|
||
)}
|
||
{msg.intentAnalysis.entities?.slice(0, 3).map((entity) => (
|
||
<span key={entity} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
|
||
{entity}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{hasProviderAttachment && 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" /> 正在读取 {providerName} 文件…
|
||
</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>
|
||
)}
|
||
{hasProviderAttachment && 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}
|
||
/>
|
||
))}
|
||
{Boolean(msg.reactions?.length) && (
|
||
<div className="mt-2 flex flex-wrap gap-1">
|
||
{msg.reactions?.map((reaction, index) => (
|
||
<span
|
||
key={`${reaction.actor}-${reaction.emoji}-${index}`}
|
||
title={reaction.fromMe ? '我' : reaction.actor}
|
||
className="inline-flex min-w-7 items-center justify-center rounded-full border border-blue-200 bg-blue-50/90 px-2 py-0.5 text-sm shadow-sm"
|
||
>
|
||
{reaction.emoji}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</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>
|
||
{msg.editedAt && !msg.isDeleted && <span>已编辑</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>
|
||
)
|
||
}
|