feat(history): complete messaging core before multi-account gateways
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
BookOpen,
|
||||
MoreHorizontal,
|
||||
Languages,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
@@ -31,6 +32,48 @@ 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,
|
||||
@@ -62,7 +105,16 @@ export function MessageBubble({
|
||||
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),
|
||||
@@ -104,6 +156,20 @@ export function MessageBubble({
|
||||
)
|
||||
|
||||
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' &&
|
||||
@@ -124,12 +190,54 @@ export function MessageBubble({
|
||||
</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">
|
||||
原文 · {languageName(msg.originalLang)}
|
||||
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
|
||||
</div>
|
||||
@@ -148,7 +256,7 @@ export function MessageBubble({
|
||||
<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 ? `${languageName(msg.originalLang)} → 英文发送内容` : `${languageName(msg.originalLang)} → 中文`}
|
||||
{isMe ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`}
|
||||
</span>
|
||||
<div className="mt-1.5">{translationContent(false)}</div>
|
||||
</div>
|
||||
@@ -156,10 +264,14 @@ export function MessageBubble({
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{languageName(msg.originalLang)}
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +307,7 @@ export function MessageBubble({
|
||||
data-testid="message-bubble"
|
||||
data-sender={msg.sender}
|
||||
className={cn(
|
||||
'group flex gap-2 px-4 py-2.5 will-change-transform',
|
||||
'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)}
|
||||
@@ -217,11 +329,13 @@ export function MessageBubble({
|
||||
)}
|
||||
{/* 消息气泡 */}
|
||||
<div
|
||||
data-testid="message-surface"
|
||||
data-direction={isMe ? 'outgoing' : 'incoming'}
|
||||
className={cn(
|
||||
'relative rounded-2xl px-3.5 py-3 shadow-sm',
|
||||
'relative isolate rounded-2xl px-3.5 py-2.5 transition-shadow',
|
||||
isMe
|
||||
? 'rounded-br-md border border-primary/15 bg-gradient-to-br from-primary-50 to-emerald-50/80'
|
||||
: 'rounded-bl-md border border-border/90 bg-card',
|
||||
? 'telegram-bubble-out rounded-br-[5px]'
|
||||
: 'telegram-bubble-in rounded-bl-[5px]',
|
||||
msg.isImportant && 'ring-1 ring-amber-400',
|
||||
)}
|
||||
>
|
||||
@@ -267,6 +381,14 @@ export function MessageBubble({
|
||||
{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>
|
||||
|
||||
{/* 底栏:时间 + 状态 */}
|
||||
|
||||
Reference in New Issue
Block a user