feat(history): complete messaging core before multi-account gateways

This commit is contained in:
wmq
2026-07-28 22:36:58 +08:00
parent cd06d049fe
commit 7f09132fad
54 changed files with 4781 additions and 601 deletions
+132 -10
View File
@@ -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>
{/* 底栏:时间 + 状态 */}