feat(history): add multi-account and WhatsApp foundation
This commit is contained in:
@@ -19,9 +19,11 @@ import {
|
||||
Download,
|
||||
FileText,
|
||||
LoaderCircle,
|
||||
SmilePlus,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { Message } from '@/types'
|
||||
import type { ChannelType, Message } from '@/types'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { toast } from '@/app/store/toast'
|
||||
import { formatTime } from '@/utils/format'
|
||||
@@ -82,8 +84,11 @@ export function MessageBubble({
|
||||
onConvertToTask,
|
||||
onAddToKnowledge,
|
||||
onAddNote,
|
||||
onReact,
|
||||
onDelete,
|
||||
customerName,
|
||||
customerAvatar,
|
||||
channel,
|
||||
}: {
|
||||
msg: Message
|
||||
viewMode: ViewMode
|
||||
@@ -92,33 +97,43 @@ export function MessageBubble({
|
||||
onConvertToTask: () => void
|
||||
onAddToKnowledge: () => void
|
||||
onAddNote: () => void
|
||||
onReact?: (emoji: string) => void
|
||||
onDelete?: () => void
|
||||
customerName?: string
|
||||
customerAvatar?: string
|
||||
channel?: ChannelType
|
||||
}) {
|
||||
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 > (msg.originalText.length <= 20 ? 160 : Math.max(500, msg.originalText.length * 8))
|
||||
rawTranslation.length > (originalText.length <= 20 ? 160 : Math.max(500, originalText.length * 8))
|
||||
const translatedText = translationCorrupted ? '' : rawTranslation
|
||||
const hasTranslation = translatedText && translatedText !== msg.originalText
|
||||
const hasTranslation = translatedText && translatedText !== originalText
|
||||
const outgoingChineseNote = isMe
|
||||
? String(
|
||||
msg.translationCn ||
|
||||
(hasTranslation && /[\u3400-\u9fff]/u.test(msg.originalText) ? msg.originalText : ''),
|
||||
(hasTranslation && /[\u3400-\u9fff]/u.test(originalText) ? originalText : ''),
|
||||
).trim()
|
||||
: ''
|
||||
const outgoingSentText = isMe && translatedText ? translatedText : msg.originalText
|
||||
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 providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram'
|
||||
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],
|
||||
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId, msg.whatsappMessageId],
|
||||
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
|
||||
enabled: Boolean(msg.telegramFileId),
|
||||
enabled: hasProviderAttachment,
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
})
|
||||
@@ -126,7 +141,7 @@ export function MessageBubble({
|
||||
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>
|
||||
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{originalText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -156,10 +171,18 @@ export function MessageBubble({
|
||||
)
|
||||
|
||||
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 msg.originalText ? (
|
||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{msg.originalText}</p>
|
||||
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" />
|
||||
@@ -167,8 +190,8 @@ export function MessageBubble({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!String(msg.originalText || '').trim()) {
|
||||
return <p className="text-xs text-slate-500">Telegram 服务通知</p>
|
||||
if (!originalText.trim()) {
|
||||
return <p className="text-xs text-slate-500">{providerName} 服务通知</p>
|
||||
}
|
||||
if (
|
||||
(viewMode === 'bilingual' || viewMode === 'translated') &&
|
||||
@@ -178,7 +201,7 @@ export function MessageBubble({
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
|
||||
<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
|
||||
@@ -239,7 +262,7 @@ export function MessageBubble({
|
||||
<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>
|
||||
<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 className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
@@ -270,7 +293,7 @@ export function MessageBubble({
|
||||
</span>
|
||||
)}
|
||||
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
|
||||
{msg.originalText}
|
||||
{originalText}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
@@ -294,12 +317,18 @@ export function MessageBubble({
|
||||
}
|
||||
|
||||
const moreActions = [
|
||||
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } },
|
||||
{ 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 (
|
||||
@@ -315,7 +344,7 @@ export function MessageBubble({
|
||||
>
|
||||
{!isMe && (
|
||||
<Avatar
|
||||
name={customerName || msg.senderName || 'Telegram 用户'}
|
||||
name={customerName || msg.senderName || `${providerName} 用户`}
|
||||
src={customerAvatar}
|
||||
size="xs"
|
||||
className="order-1 mt-1"
|
||||
@@ -348,9 +377,9 @@ export function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
{msg.telegramFileId && attachmentLoading && (
|
||||
{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" /> 正在读取 Telegram 文件…
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 {providerName} 文件…
|
||||
</div>
|
||||
)}
|
||||
{attachment && msg.type === 'image' && (
|
||||
@@ -378,7 +407,7 @@ export function MessageBubble({
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
)}
|
||||
{msg.telegramFileId && attachmentError && (
|
||||
{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) => (
|
||||
@@ -389,11 +418,25 @@ export function MessageBubble({
|
||||
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" />}
|
||||
|
||||
Reference in New Issue
Block a user