feat(history): establish Telegram SaaS platform foundation

This commit is contained in:
wmq
2026-07-27 23:03:00 +08:00
parent cf0eed46c3
commit cd06d049fe
92 changed files with 5608 additions and 3749 deletions
+158 -28
View File
@@ -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>
{/* 底栏:时间 + 状态 */}