feat(history): establish Telegram SaaS platform foundation
This commit is contained in:
@@ -12,14 +12,22 @@ import {
|
||||
ListTodo,
|
||||
BookOpen,
|
||||
MoreHorizontal,
|
||||
Languages,
|
||||
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 { Badge } from '@/components/ui/badge'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { getMessageAttachment } from '@/services/api/conversations'
|
||||
|
||||
export type ViewMode = 'original' | 'translated' | 'bilingual'
|
||||
|
||||
@@ -31,6 +39,8 @@ export function MessageBubble({
|
||||
onConvertToTask,
|
||||
onAddToKnowledge,
|
||||
onAddNote,
|
||||
customerName,
|
||||
customerAvatar,
|
||||
}: {
|
||||
msg: Message
|
||||
viewMode: ViewMode
|
||||
@@ -39,11 +49,27 @@ export function MessageBubble({
|
||||
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 hasTranslation = msg.translatedText && msg.translatedText !== 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))
|
||||
const translatedText = translationCorrupted ? '' : rawTranslation
|
||||
const hasTranslation = translatedText && translatedText !== msg.originalText
|
||||
const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6
|
||||
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 (
|
||||
@@ -53,33 +79,87 @@ export function MessageBubble({
|
||||
)
|
||||
}
|
||||
|
||||
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 (viewMode === 'bilingual' && hasTranslation) {
|
||||
if (
|
||||
(viewMode === 'bilingual' || viewMode === 'translated') &&
|
||||
msg.sender === 'customer' &&
|
||||
msg.type === 'text' &&
|
||||
(!translatedText || translationCorrupted)
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground">{languageName(msg.originalLang)}</span>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.originalText}</p>
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-2">
|
||||
<span className="text-[10px] text-muted-foreground">中文释义</span>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm text-muted-foreground">{msg.translatedText}</p>
|
||||
<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 (viewMode === 'translated' && msg.translatedText) {
|
||||
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)}
|
||||
</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="text-[10px] text-muted-foreground">{languageName(msg.originalLang)} → 中文</span>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.translatedText}</p>
|
||||
<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)} → 中文`}
|
||||
</span>
|
||||
<div className="mt-1.5">{translationContent(false)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground">{languageName(msg.originalLang)}</span>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.originalText}</p>
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -104,7 +184,6 @@ export function MessageBubble({
|
||||
const moreActions = [
|
||||
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } },
|
||||
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
|
||||
{ key: 'forward', label: '转发', icon: Forward, onSelect: () => toast.info('转发功能:选择目标会话(演示)') },
|
||||
{ key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
|
||||
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
|
||||
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
|
||||
@@ -113,30 +192,81 @@ export function MessageBubble({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group flex gap-2 px-4 py-2.5', isMe ? 'justify-end' : 'justify-start')}
|
||||
data-testid="message-bubble"
|
||||
data-sender={msg.sender}
|
||||
className={cn(
|
||||
'group flex gap-2 px-4 py-2.5 will-change-transform',
|
||||
isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
|
||||
)}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
<div className={cn('max-w-[70%] min-w-0', isMe ? 'order-2' : 'order-1')}>
|
||||
{!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
|
||||
className={cn(
|
||||
'relative rounded-lg px-3 py-2.5',
|
||||
isMe ? 'bg-primary-50' : 'bg-card border border-border',
|
||||
'relative rounded-2xl px-3.5 py-3 shadow-sm',
|
||||
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',
|
||||
msg.isImportant && 'ring-1 ring-amber-400',
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
{msg.attachmentName && msg.type === 'image' && (
|
||||
<div className="mt-2 flex h-24 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
|
||||
📷 {msg.attachmentName}
|
||||
{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>
|
||||
)}
|
||||
{msg.attachmentName && msg.type === 'file' && (
|
||||
<div className="mt-1.5 rounded bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
📎 {msg.attachmentName}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底栏:时间 + 状态 */}
|
||||
|
||||
Reference in New Issue
Block a user