| e.stopPropagation()}> setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /> |
navigate(`/customers/${customer.id}`)}>
diff --git a/src/pages/dashboard/DashboardPage.tsx b/src/pages/dashboard/DashboardPage.tsx
index 76a0688..9d75c45 100644
--- a/src/pages/dashboard/DashboardPage.tsx
+++ b/src/pages/dashboard/DashboardPage.tsx
@@ -92,43 +92,53 @@ export function DashboardPage() {
const s = data.stats
return (
-
+
{/* 顶部欢迎区 */}
-
-
-
- {greeting},{user?.name}
-
-
- {dateStr} · {tenant?.name} · {t('dashboard.updatedAt')} {formatDateTime(data.updatedAt).slice(11)}
-
+
+
+
+ Business command center
+
+ {greeting},{user?.name}
+
+
+ {dateStr} · {tenant?.name} · 数据更新于 {formatDateTime(data.updatedAt).slice(11)}
+
+
+
+ {plan.name}
+
+
+
-
- {plan.name}
-
-
+
+ {quickActions.slice(0, 5).map((a) => (
+
+ ))}
- {/* 快捷操作 */}
-
- {quickActions.map((a) => (
-
- ))}
-
-
{/* 核心指标 */}
+
+
+
+
navigate('/inbox')} />
navigate('/inbox?filter=pending')} />
navigate('/customers?intentLevel=high')} />
navigate('/opportunities?tab=tasks')} />
+
+
navigate('/risks')} />
diff --git a/src/pages/inbox/InboxPage.tsx b/src/pages/inbox/InboxPage.tsx
index da93d0b..8685981 100644
--- a/src/pages/inbox/InboxPage.tsx
+++ b/src/pages/inbox/InboxPage.tsx
@@ -1,7 +1,23 @@
import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { ArrowLeft, UserRound, ChevronLeft, MessageCircle, X } from 'lucide-react'
+import {
+ Archive,
+ ArrowLeft,
+ Ban,
+ BellOff,
+ Check,
+ ChevronLeft,
+ Eraser,
+ LogOut,
+ MessageCircle,
+ MoreHorizontal,
+ Palette,
+ Pin,
+ Trash2,
+ UserRound,
+ X,
+} from 'lucide-react'
import {
getConversations,
getConversation,
@@ -12,6 +28,10 @@ import {
updateConversation,
updateMessage,
markConversationRead,
+ openTelegramConversation,
+ runTelegramConversationAction,
+ type TelegramConversationAction,
+ type ConversationListItem,
} from '@/services/api/conversations'
import { updateCustomer } from '@/services/api/customers'
import { useAuthStore } from '@/app/store/auth'
@@ -20,11 +40,11 @@ import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { Button } from '@/components/ui/button'
import { Dialog } from '@/components/ui/dialog'
-import { Segmented, type TabItem } from '@/components/ui/tabs'
+import { Dropdown } from '@/components/ui/dropdown'
+import { Segmented } from '@/components/ui/tabs'
import { Avatar } from '@/components/ui/avatar'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { CountryFlag } from '@/components/common/CountryFlag'
-import { IntentScoreBadge } from '@/components/common/StatusBadges'
import type { ViewMode } from '@/components/inbox/MessageBubble'
import type { Conversation, Customer, Message } from '@/types'
import { ConversationList } from '@/components/inbox/ConversationList'
@@ -35,6 +55,50 @@ import { AiReplies } from '@/components/inbox/AiReplies'
import { ConversationInsightBar } from '@/components/inbox/ConversationInsightBar'
import { useDebounce } from '@/hooks/useDebounce'
import { getWebSocketTicket } from '@/services/api/auth'
+import { cn } from '@/utils/cn'
+
+type ChatWallpaper = 'ocean' | 'aurora' | 'sunset' | 'mint'
+
+const CHAT_WALLPAPERS: Array<{
+ value: ChatWallpaper
+ label: string
+ swatch: string
+}> = [
+ { value: 'ocean', label: '清爽蓝', swatch: 'from-blue-300 via-sky-100 to-white' },
+ { value: 'aurora', label: '极光紫', swatch: 'from-violet-300 via-fuchsia-100 to-cyan-100' },
+ { value: 'sunset', label: '日落橙', swatch: 'from-orange-200 via-rose-100 to-violet-100' },
+ { value: 'mint', label: '薄荷绿', swatch: 'from-emerald-200 via-teal-100 to-sky-100' },
+]
+
+function sortRealtimeConversations(items: ConversationListItem[]) {
+ return [...items].sort((a, b) => {
+ const pinned = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned))
+ if (pinned) return pinned
+ const bTime = Date.parse(b.lastMessage?.createdAt || b.updatedAt || '') || 0
+ const aTime = Date.parse(a.lastMessage?.createdAt || a.updatedAt || '') || 0
+ if (bTime !== aTime) return bTime - aTime
+ return String(b.id).localeCompare(String(a.id))
+ })
+}
+
+function updateRealtimeConversationList(
+ current: { list: ConversationListItem[]; total: number } | undefined,
+ conversationId: string,
+ patch: Partial,
+) {
+ if (!current) return current
+ let matched = false
+ const list = current.list.map((item) => {
+ if (item.id !== conversationId) return item
+ matched = true
+ return {
+ ...item,
+ ...patch,
+ customer: patch.customer ? { ...item.customer, ...patch.customer } : item.customer,
+ }
+ })
+ return matched ? { ...current, list: sortRealtimeConversations(list) } : current
+}
export function InboxPage() {
const [searchParams, setSearchParams] = useSearchParams()
@@ -48,11 +112,21 @@ export function InboxPage() {
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, 300)
- const [viewMode, setViewMode] = useState('bilingual')
+ const [viewMode, setViewMode] = useState(() => {
+ const saved = window.localStorage.getItem('lingxi-message-view-mode')
+ return ['original', 'translated', 'bilingual'].includes(String(saved))
+ ? saved as ViewMode
+ : 'bilingual'
+ })
const [mobilePanel, setMobilePanel] = useState<'list' | 'chat' | 'insight'>('list')
const [showInsight, setShowInsight] = useState(false) // desktop right panel toggle
- const [showAiReplies, setShowAiReplies] = useState(true)
+ const [showAiReplies, setShowAiReplies] = useState(false)
+ const [chatWallpaper, setChatWallpaper] = useState(() => {
+ const saved = window.localStorage.getItem('lingxi-chat-wallpaper')
+ return CHAT_WALLPAPERS.some((item) => item.value === saved) ? saved as ChatWallpaper : 'ocean'
+ })
const [draftText, setDraftText] = useState('')
+ const [draftTranslationCn, setDraftTranslationCn] = useState('')
const [replyingTo, setReplyingTo] = useState<{ id: string; text: string } | null>(null)
const [noteTarget, setNoteTarget] = useState(null)
const [noteDraft, setNoteDraft] = useState('')
@@ -61,15 +135,38 @@ export function InboxPage() {
message: Message
customer: Customer
} | null>(null)
+ const [addTelegramOpen, setAddTelegramOpen] = useState(false)
+ const [addTelegramType, setAddTelegramType] = useState<'user' | 'chat' | 'invite'>('user')
+ const [addTelegramIdentifier, setAddTelegramIdentifier] = useState('')
+ const [addTelegramLoading, setAddTelegramLoading] = useState(false)
+ const [conversationActionTarget, setConversationActionTarget] = useState<{
+ item: ConversationListItem
+ action: 'clear' | 'delete' | 'leave'
+ } | null>(null)
+ const [conversationActionLoading, setConversationActionLoading] = useState(false)
+
const messageListRef = useRef(null)
+ const messageContentRef = useRef(null)
+ const activeConversationQueryKeyRef = useRef([
+ 'conversations',
+ filter,
+ channel,
+ debouncedKeyword,
+ ])
+ activeConversationQueryKeyRef.current = ['conversations', filter, channel, debouncedKeyword]
useEffect(() => {
setDraftText('')
+ setDraftTranslationCn('')
setReplyingTo(null)
setShowAiReplies(true)
if (isMobile && convId) setMobilePanel('chat')
}, [convId])
+ useEffect(() => {
+ window.localStorage.setItem('lingxi-chat-wallpaper', chatWallpaper)
+ }, [chatWallpaper])
+
// 会话列表
const { data: convData, isLoading: convLoading } = useQuery({
queryKey: ['conversations', filter, channel, debouncedKeyword],
@@ -108,6 +205,34 @@ export function InboxPage() {
return () => cancelAnimationFrame(frame)
}, [convId, messages?.length, lastMessageId])
+ // Keep the newest message visible while translations, attachments and the
+ // surrounding composer finish measuring. Do not override a user scrolling up.
+ useEffect(() => {
+ const container = messageListRef.current
+ const content = messageContentRef.current
+ if (!container || !content || !convId) return
+
+ let initialLayout = true
+ const scrollIfFollowingLatest = () => {
+ const distanceFromBottom =
+ container.scrollHeight - container.clientHeight - container.scrollTop
+ if (initialLayout || distanceFromBottom <= 160) {
+ container.scrollTop = container.scrollHeight
+ }
+ initialLayout = false
+ }
+
+ const frame = requestAnimationFrame(scrollIfFollowingLatest)
+ const observer = new ResizeObserver(scrollIfFollowingLatest)
+ observer.observe(container)
+ observer.observe(content)
+
+ return () => {
+ cancelAnimationFrame(frame)
+ observer.disconnect()
+ }
+ }, [convId])
+
useEffect(() => {
if (!convId || !messages?.length) return
const unreadIds = messages.filter((message) => message.sender === 'customer').slice(-100).map((message) => message.id)
@@ -160,8 +285,15 @@ export function InboxPage() {
conversation?: Conversation
}
try { event = JSON.parse(String(raw.data)) } catch { return }
+ const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>(
+ activeConversationQueryKeyRef.current,
+ )
+ const conversationWasVisible = Boolean(
+ event.conversationId
+ && activeConversationCache?.list.some((item) => item.id === event.conversationId),
+ )
if (
- event.type === 'message.created' &&
+ ['message.created', 'message.received'].includes(event.type || '') &&
event.conversationId &&
event.message?.sender === 'customer' &&
event.customer
@@ -185,27 +317,38 @@ export function InboxPage() {
})
queryClient.setQueriesData<{ list: Array<{ id: string; lastMessage?: Message }>; total: number }>(
{ queryKey: ['conversations'] },
- (current) => current ? {
- ...current,
- list: current.list.map((item) => item.id === event.conversationId
- ? { ...item, lastMessage: event.message }
- : item),
- } : current,
+ (current) => {
+ const typed = current as { list: ConversationListItem[]; total: number } | undefined
+ const item = typed?.list.find((entry) => entry.id === event.conversationId)
+ const isNewMessage = ['message.created', 'message.received'].includes(event.type || '')
+ const isCurrentLastMessage =
+ String(item?.lastMessage?.id || '') === String(event.message?.id || '')
+ if (!isNewMessage && !isCurrentLastMessage) return typed
+ return updateRealtimeConversationList(typed, event.conversationId!, {
+ lastMessage: event.message,
+ })
+ },
)
}
- if (event.conversationId && event.customer && event.conversation) {
- queryClient.setQueryData(['conversation', event.conversationId], {
+ if (event.conversationId && event.conversation) {
+ queryClient.setQueryData(['conversation', event.conversationId], (current: any) => ({
conversation: event.conversation,
- customer: event.customer,
- })
- queryClient.setQueriesData({ queryKey: ['conversations'] }, (current: any) => current ? {
- ...current,
- list: current.list.map((item: any) => item.id === event.conversationId
- ? { ...item, ...event.conversation, customer: event.customer }
- : item),
- } : current)
+ customer: event.customer || current?.customer,
+ }))
+ queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>(
+ { queryKey: ['conversations'] },
+ (current) => updateRealtimeConversationList(current, event.conversationId!, {
+ ...event.conversation,
+ ...(event.customer ? { customer: event.customer } : {}),
+ }),
+ )
+ }
+ if (
+ event.type === 'sync.completed'
+ || (event.conversationId && !conversationWasVisible)
+ ) {
+ void queryClient.invalidateQueries({ queryKey: ['conversations'] })
}
- void queryClient.invalidateQueries({ queryKey: ['conversations'] })
}
socket.onerror = () => socket?.close()
socket.onclose = () => {
@@ -245,19 +388,34 @@ export function InboxPage() {
[setSearchParams],
)
+ const setChannel = useCallback(
+ (value: string) => {
+ setSearchParams((prev) => {
+ if (value) prev.set('channel', value)
+ else prev.delete('channel')
+ prev.delete('c')
+ return prev
+ })
+ },
+ [setSearchParams],
+ )
+
const sendMsg = useMutation({
mutationFn: async ({
text,
convId: id,
autoTranslate,
+ translationCn,
}: {
text: string
convId: string
autoTranslate?: boolean
+ translationCn?: string
}) => {
return sendMessage(id, {
text,
autoTranslate,
+ translationCn,
replyToId: replyingTo?.id,
replyPreview: replyingTo?.text,
})
@@ -272,7 +430,13 @@ export function InboxPage() {
const handleSend = async (text: string, autoTranslate = true) => {
if (!convId) return
- await sendMsg.mutateAsync({ text, convId, autoTranslate })
+ await sendMsg.mutateAsync({
+ text,
+ convId,
+ autoTranslate,
+ translationCn: draftTranslationCn || undefined,
+ })
+ setDraftTranslationCn('')
}
const handleMedia = async (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => {
@@ -293,6 +457,87 @@ export function InboxPage() {
for (const item of encoded) await sendMedia(convId, { ...item, kind })
}
+ const refreshConversationState = async (id?: string) => {
+ await queryClient.invalidateQueries({ queryKey: ['conversations'] })
+ if (id) {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['conversation', id] }),
+ queryClient.invalidateQueries({ queryKey: ['messages', id] }),
+ ])
+ }
+ }
+
+ const executeTelegramAction = async (
+ action: TelegramConversationAction,
+ item?: ConversationListItem,
+ ) => {
+ const id = item?.id || convId
+ if (!id) return
+ try {
+ const result = await runTelegramConversationAction(id, action)
+ if ('deleted' in result && result.deleted) {
+ queryClient.removeQueries({ queryKey: ['messages', id] })
+ queryClient.removeQueries({ queryKey: ['conversation', id] })
+ if (convId === id) setSearchParams((params) => { params.delete('c'); return params })
+ } else {
+ await refreshConversationState(id)
+ }
+ const labels: Partial> = {
+ clear: '消息记录已清空',
+ delete: '会话已从 Telegram 列表删除',
+ pin: '会话已置顶',
+ unpin: '已取消置顶',
+ archive: '会话已归档',
+ unarchive: '会话已移出归档',
+ mute: '会话已静音 8 小时',
+ unmute: '会话已恢复通知',
+ 'mark-unread': '会话已标记为未读',
+ leave: '已退出群组或频道',
+ block: '已拉黑该 Telegram 用户',
+ unblock: '已解除拉黑',
+ }
+ toast.success(labels[action] || 'Telegram 操作已完成')
+ } catch (error) {
+ toast.error(errorMessage(error))
+ throw error
+ }
+ }
+
+ const confirmTelegramAction = async () => {
+ if (!conversationActionTarget) return
+ setConversationActionLoading(true)
+ try {
+ await executeTelegramAction(conversationActionTarget.action, conversationActionTarget.item)
+ setConversationActionTarget(null)
+ } finally {
+ setConversationActionLoading(false)
+ }
+ }
+
+ const addTelegramConversation = async () => {
+ const identifier = addTelegramIdentifier.trim()
+ if (!identifier) {
+ toast.error('请输入 Telegram 标识')
+ return
+ }
+ setAddTelegramLoading(true)
+ try {
+ const value = await openTelegramConversation({
+ identifier,
+ targetType: addTelegramType,
+ })
+ await refreshConversationState(value.conversation.id)
+ setAddTelegramOpen(false)
+ setAddTelegramIdentifier('')
+ setConv(value.conversation.id)
+ toast.success('Telegram 会话已添加', value.customer.name)
+ } catch (error) {
+ toast.error(errorMessage(error))
+ } finally {
+ setAddTelegramLoading(false)
+ }
+ }
+
const handleAction = {
quote: (msg: Message) => {
if (!convId) return
@@ -391,8 +636,127 @@ export function InboxPage() {
)
- const handleAdopt = (content: string) => {
+ const telegramDialogs = (
+ <>
+
+
+
+ >
+ )
+
+ const handleAdopt = (content: string, translationCn: string) => {
setDraftText(content)
+ setDraftTranslationCn(translationCn)
setShowAiReplies(false)
if (convId) {
void import('@/services/api/ai').then(({ recordAiAdoption }) => recordAiAdoption(convId, content))
@@ -400,25 +764,64 @@ export function InboxPage() {
toast.success('回复已采用,请在输入区编辑后发送')
}
- const handleEdit = (content: string) => {
+ const handleEdit = (content: string, translationCn: string) => {
setDraftText(content)
+ setDraftTranslationCn(translationCn)
setShowAiReplies(false)
toast.info('回复已加载到输入区,请按需编辑', content.slice(0, 50))
}
- const VIEW_LABELS: TabItem[] = [
- { value: 'original', label: '原文' },
- { value: 'translated', label: '译文' },
- { value: 'bilingual', label: '双语' },
- ]
+ const handleViewModeChange = (nextMode: ViewMode) => {
+ setViewMode(nextMode)
+ window.localStorage.setItem('lingxi-message-view-mode', nextMode)
+ }
const listItems = convData?.list ?? []
+ const renderMessageTimeline = () => {
+ let previousDay = ''
+ const today = new Date().toDateString()
+ const yesterday = new Date(Date.now() - 86_400_000).toDateString()
+ return (messages ?? []).map((msg) => {
+ const messageDate = new Date(msg.createdAt)
+ const dayKey = messageDate.toDateString()
+ const showDivider = dayKey !== previousDay
+ previousDay = dayKey
+ const dayLabel = dayKey === today
+ ? '今天'
+ : dayKey === yesterday
+ ? '昨天'
+ : new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(messageDate)
+ return (
+
+ {showDivider && (
+
+
+ {dayLabel}
+
+
+ )}
+ handleAction.quote(msg)}
+ onMarkImportant={() => handleAction.markImportant(msg)}
+ onConvertToTask={() => handleAction.task(msg)}
+ onAddToKnowledge={() => handleAction.knowledge(msg)}
+ onAddNote={() => handleAction.addNote(msg)}
+ customerName={convDetail?.customer.name}
+ customerAvatar={convDetail?.customer.avatarUrl}
+ />
+
+ )
+ })
+ }
// 移动端:当前面板
if (isMobile) {
return (
-
+
{noteDialog}
+ {telegramDialogs}
{incomingNotice && (
setAddTelegramOpen(true)}
+ onClear={(item) => setConversationActionTarget({ item, action: 'clear' })}
+ onDelete={(item) => setConversationActionTarget({ item, action: 'delete' })}
/>
)}
{mobilePanel === 'chat' && (
-
+
{
+ const item = listItems.find((entry) => entry.id === convId)
+ if (!item) return
+ if (['clear', 'delete', 'leave'].includes(action)) {
+ setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' })
+ } else {
+ void executeTelegramAction(action, item)
+ }
+ }}
onBack={() => setMobilePanel('list')}
onInsight={() => setMobilePanel('insight')}
showInsightBtn
@@ -460,23 +879,17 @@ export function InboxPage() {
onOpenProfile={() => setMobilePanel('insight')}
/>
)}
-
- {msgsLoading ? : <>
- {(messages ?? []).map((msg) => (
- handleAction.quote(msg)}
- onMarkImportant={() => handleAction.markImportant(msg)}
- onConvertToTask={() => handleAction.task(msg)}
- onAddToKnowledge={() => handleAction.knowledge(msg)}
- onAddNote={() => handleAction.addNote(msg)}
- customerName={convDetail?.customer.name}
- customerAvatar={convDetail?.customer.avatarUrl}
- />
- ))}
- >}
+
+ 0 && 'flex flex-col justify-end',
+ )}
+ >
+ {msgsLoading ? : renderMessageTimeline()}
+
{showAiReplies && convId ? (
{
+ setDraftText(text)
+ setDraftTranslationCn('')
+ }}
disabled={!convId}
replyingTo={replyingTo}
onCancelReply={() => setReplyingTo(null)}
+ channel={convDetail?.conversation.channel}
/>
)}
@@ -520,8 +937,9 @@ export function InboxPage() {
// 桌面端三栏布局
return (
-
+
{noteDialog}
+ {telegramDialogs}
{incomingNotice && (
)}
{/* 左栏:会话列表 360px */}
-
+
setAddTelegramOpen(true)}
+ onClear={(item) => setConversationActionTarget({ item, action: 'clear' })}
+ onDelete={(item) => setConversationActionTarget({ item, action: 'delete' })}
/>
{/* 中栏:聊天区 flex-1 */}
-
+
{convId ? (
<>
{
+ const item = listItems.find((entry) => entry.id === convId)
+ if (!item) return
+ if (['clear', 'delete', 'leave'].includes(action)) {
+ setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' })
+ } else {
+ void executeTelegramAction(action, item)
+ }
+ }}
onInsight={() => setShowInsight((v) => !v)}
showInsightBtn
/>
@@ -566,27 +1006,21 @@ export function InboxPage() {
onOpenProfile={() => setShowInsight(true)}
/>
)}
-
- {msgsLoading ? (
-
- ) : (messages?.length ?? 0) === 0 ? (
-
- ) : (
- (messages ?? []).map((msg) => (
- handleAction.quote(msg)}
- onMarkImportant={() => handleAction.markImportant(msg)}
- onConvertToTask={() => handleAction.task(msg)}
- onAddToKnowledge={() => handleAction.knowledge(msg)}
- onAddNote={() => handleAction.addNote(msg)}
- customerName={convDetail?.customer.name}
- customerAvatar={convDetail?.customer.avatarUrl}
- />
- ))
- )}
+
+ 0 && 'flex flex-col justify-end',
+ )}
+ >
+ {msgsLoading ? (
+
+ ) : (messages?.length ?? 0) === 0 ? (
+
+ ) : renderMessageTimeline()}
+
{showAiReplies ? (
{
+ setDraftText(text)
+ setDraftTranslationCn('')
+ }}
disabled={!convId}
replyingTo={replyingTo}
onCancelReply={() => setReplyingTo(null)}
+ channel={convDetail?.conversation.channel}
/>
>
) : (
@@ -623,7 +1061,7 @@ export function InboxPage() {
{/* 右栏:客户洞察 340px */}
{showInsight && convDetail && (
-
+
)}
@@ -638,6 +1076,9 @@ function ChatHeader({
loading,
viewMode,
onViewModeChange,
+ wallpaper,
+ onWallpaperChange,
+ onTelegramAction,
onBack,
onInsight,
showInsightBtn,
@@ -647,6 +1088,9 @@ function ChatHeader({
loading: boolean
viewMode: ViewMode
onViewModeChange: (v: ViewMode) => void
+ wallpaper: ChatWallpaper
+ onWallpaperChange: (wallpaper: ChatWallpaper) => void
+ onTelegramAction: (action: TelegramConversationAction) => void
onBack?: () => void
onInsight?: () => void
showInsightBtn?: boolean
@@ -654,8 +1098,9 @@ function ChatHeader({
if (!convId) return null
const c = detail?.customer
const conv = detail?.conversation
+ const isTelegramChannel = String(conv?.telegramChatType || c?.telegramChatType || '').toLowerCase() === 'channel'
return (
-
+
{onBack && (
+ )}
+ items={CHAT_WALLPAPERS.map((item) => ({
+ key: item.value,
+ label: (
+
+
+ {item.label}
+ {wallpaper === item.value && }
+
+ ),
+ onSelect: () => onWallpaperChange(item.value),
+ }))}
+ />
+ {conv?.channel === 'telegram' && (
+
+
+
+ )}
+ items={[
+ {
+ key: conv.isPinned ? 'unpin' : 'pin',
+ label: conv.isPinned ? '取消置顶' : '置顶会话',
+ icon: Pin,
+ onSelect: () => onTelegramAction(conv.isPinned ? 'unpin' : 'pin'),
+ },
+ {
+ key: conv.archived ? 'unarchive' : 'archive',
+ label: conv.archived ? '移出归档' : '归档会话',
+ icon: Archive,
+ onSelect: () => onTelegramAction(conv.archived ? 'unarchive' : 'archive'),
+ },
+ {
+ key: conv.mutedUntil ? 'unmute' : 'mute',
+ label: conv.mutedUntil ? '恢复通知' : '静音 8 小时',
+ icon: BellOff,
+ onSelect: () => onTelegramAction(conv.mutedUntil ? 'unmute' : 'mute'),
+ },
+ {
+ key: 'mark-unread',
+ label: '标记为未读',
+ icon: MessageCircle,
+ onSelect: () => onTelegramAction('mark-unread'),
+ },
+ {
+ key: conv.isBlocked ? 'unblock' : 'block',
+ label: conv.isBlocked ? '解除拉黑' : '拉黑用户',
+ icon: Ban,
+ disabled: !c?.telegramUserId,
+ onSelect: () => onTelegramAction(conv.isBlocked ? 'unblock' : 'block'),
+ },
+ 'separator',
+ ...(!isTelegramChannel
+ ? [{
+ key: 'clear',
+ label: '清空消息',
+ icon: Eraser,
+ onSelect: () => onTelegramAction('clear' as TelegramConversationAction),
+ }]
+ : []),
+ ...(String(conv.telegramChatType || c?.telegramChatType || '').toLowerCase() !== 'private'
+ ? [{
+ key: 'leave',
+ label: '退出群组/频道',
+ icon: LogOut,
+ danger: true,
+ onSelect: () => onTelegramAction('leave' as TelegramConversationAction),
+ }]
+ : []),
+ {
+ key: 'delete',
+ label: '删除会话',
+ icon: Trash2,
+ danger: true,
+ onSelect: () => onTelegramAction('delete'),
+ },
+ ]}
+ />
+ )}
{showInsightBtn && onInsight && (
diff --git a/src/services/api/ai.ts b/src/services/api/ai.ts
index f2b76b7..ec25081 100644
--- a/src/services/api/ai.ts
+++ b/src/services/api/ai.ts
@@ -1,11 +1,19 @@
import { http } from './client'
-import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult } from '@/types'
+import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult, TranslationHistoryItem } from '@/types'
/** POST /api/ai/translate */
export function translate(payload: TranslatePayload) {
return http.post ('/api/ai/translate', payload)
}
+export function getTranslationHistory() {
+ return http.get('/api/ai/translations')
+}
+
+export function favoriteTranslation(id: string, favorite: boolean) {
+ return http.patch(`/api/ai/translations/${id}`, { favorite })
+}
+
/** POST /api/ai/analyze */
export function analyzeConversation(conversationId: string) {
return http.post('/api/ai/analyze', { conversationId })
diff --git a/src/services/api/channels.ts b/src/services/api/channels.ts
index 228d436..8cfb9b7 100644
--- a/src/services/api/channels.ts
+++ b/src/services/api/channels.ts
@@ -21,6 +21,23 @@ export function startTelegramLogin(phone: string) {
return http.post('/api/channels/telegram/start', { phone })
}
+export function connectEmailChannel(payload: {
+ name?: string
+ displayName?: string
+ account: string
+ password: string
+ imapHost: string
+ imapPort: number
+ imapSecure: boolean
+ imapFolder?: string
+ smtpHost: string
+ smtpPort: number
+ smtpSecure: boolean
+ syncDays?: number
+}) {
+ return http.post('/api/channels/email/connect', payload)
+}
+
export function submitTelegramCode(channelId: string, code: string) {
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/code`, { code })
}
@@ -29,6 +46,12 @@ export function submitTelegramPassword(channelId: string, password: string) {
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/password`, { password })
}
+export function getTelegramLoginState(channelId: string) {
+ return http.post<{ status: string; connectionState?: string; channel: ChannelConnection }>(
+ `/api/channels/telegram/${channelId}/state`,
+ )
+}
+
export function syncTelegramChannel(channelId: string) {
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`)
}
diff --git a/src/services/api/conversations.ts b/src/services/api/conversations.ts
index 1f3fe6b..8e84b4f 100644
--- a/src/services/api/conversations.ts
+++ b/src/services/api/conversations.ts
@@ -33,9 +33,10 @@ export function markConversationRead(id: string, messageIds?: string[]) {
return http.post(`/api/conversations/${id}/read`, { messageIds })
}
-export function getMessageAttachment(id: string, messageId: string) {
+export function getMessageAttachment(id: string, messageId: string, attachmentId?: string) {
return http.get<{ dataUrl: string; mimeType: string; name: string }>(
`/api/conversations/${id}/messages/${messageId}/attachment`,
+ attachmentId ? { attachmentId } : undefined,
)
}
@@ -62,3 +63,39 @@ export function sendAlbum(
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
return http.patch(`/api/conversations/${id}`, payload)
}
+
+export type TelegramConversationAction =
+ | 'clear'
+ | 'delete'
+ | 'pin'
+ | 'unpin'
+ | 'archive'
+ | 'unarchive'
+ | 'mute'
+ | 'unmute'
+ | 'mark-unread'
+ | 'leave'
+ | 'block'
+ | 'unblock'
+
+export function runTelegramConversationAction(
+ id: string,
+ action: TelegramConversationAction,
+ options: { seconds?: number } = {},
+) {
+ return http.post(
+ `/api/conversations/${id}/actions`,
+ { action, ...options },
+ )
+}
+
+export function openTelegramConversation(payload: {
+ identifier: string
+ targetType: 'user' | 'chat' | 'invite'
+ channelId?: string
+}) {
+ return http.post<{ conversation: Conversation; customer: Customer }>(
+ '/api/conversations/telegram/open',
+ payload,
+ )
+}
diff --git a/src/styles/index.css b/src/styles/index.css
index bb48aa4..7dc0282 100644
--- a/src/styles/index.css
+++ b/src/styles/index.css
@@ -4,26 +4,26 @@
@layer base {
:root {
- --background: 220 14% 96%;
- --foreground: 215 25% 16%;
+ --background: 210 20% 98%;
+ --foreground: 215 28% 13%;
--card: 0 0% 100%;
--card-foreground: 215 25% 16%;
--popover: 0 0% 100%;
--popover-foreground: 215 25% 16%;
- --primary: 172 59% 30%;
+ --primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
- --secondary: 210 16% 93%;
+ --secondary: 210 20% 96%;
--secondary-foreground: 215 20% 24%;
- --muted: 210 16% 93%;
- --muted-foreground: 215 12% 46%;
- --accent: 170 40% 93%;
- --accent-foreground: 172 60% 22%;
+ --muted: 210 20% 96%;
+ --muted-foreground: 215 12% 43%;
+ --accent: 214 100% 97%;
+ --accent-foreground: 221 83% 40%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%;
- --border: 214 15% 88%;
- --input: 214 15% 84%;
- --ring: 172 59% 34%;
- --radius: 0.5rem;
+ --border: 214 22% 91%;
+ --input: 214 18% 86%;
+ --ring: 221 83% 53%;
+ --radius: 0.75rem;
}
* {
@@ -40,6 +40,10 @@
@apply bg-background text-foreground antialiased;
font-size: 14px;
line-height: 1.6;
+ letter-spacing: -0.006em;
+ background-image:
+ radial-gradient(circle at 75% -10%, rgba(37, 99, 235, 0.05), transparent 26rem),
+ linear-gradient(180deg, #ffffff 0, hsl(var(--background)) 22rem);
}
::selection {
@@ -65,6 +69,104 @@
.text-balance {
text-wrap: balance;
}
+ .surface-panel {
+ @apply rounded-2xl border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.03),0_10px_30px_rgba(15,23,42,0.025)];
+ }
+ .eyebrow {
+ @apply text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-700;
+ }
+}
+
+.chat-wallpaper {
+ transition: background-color .28s ease, background-image .28s ease;
+ background-position: center, center, center, center, 0 0, center;
+ background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%, 320px 320px, 100% 100%;
+}
+
+.chat-wallpaper-ocean {
+ background-color: #eaf2f9;
+ background-image:
+ radial-gradient(ellipse at 8% 10%, rgba(125, 211, 252, .42), transparent 45%),
+ radial-gradient(ellipse at 92% 8%, rgba(96, 165, 250, .34), transparent 44%),
+ radial-gradient(ellipse at 12% 92%, rgba(45, 212, 191, .24), transparent 43%),
+ radial-gradient(ellipse at 90% 90%, rgba(129, 140, 248, .25), transparent 46%),
+ url('/chat-pattern.svg'),
+ linear-gradient(145deg, rgba(245, 251, 255, .88), rgba(224, 236, 250, .94));
+}
+
+.chat-wallpaper-aurora {
+ background-color: #eff0ff;
+ background-image:
+ radial-gradient(ellipse at 8% 8%, rgba(167, 139, 250, .38), transparent 46%),
+ radial-gradient(ellipse at 92% 12%, rgba(244, 114, 182, .3), transparent 43%),
+ radial-gradient(ellipse at 10% 92%, rgba(34, 211, 238, .3), transparent 45%),
+ radial-gradient(ellipse at 90% 90%, rgba(99, 102, 241, .32), transparent 47%),
+ url('/chat-pattern.svg'),
+ linear-gradient(145deg, rgba(249, 244, 255, .9), rgba(228, 246, 255, .88));
+}
+
+.chat-wallpaper-sunset {
+ background-color: #fff3ed;
+ background-image:
+ radial-gradient(ellipse at 8% 10%, rgba(251, 191, 36, .34), transparent 44%),
+ radial-gradient(ellipse at 92% 8%, rgba(251, 113, 133, .33), transparent 45%),
+ radial-gradient(ellipse at 9% 92%, rgba(251, 146, 60, .28), transparent 43%),
+ radial-gradient(ellipse at 90% 90%, rgba(192, 132, 252, .28), transparent 47%),
+ url('/chat-pattern.svg'),
+ linear-gradient(145deg, rgba(255, 249, 232, .92), rgba(250, 235, 255, .9));
+}
+
+.chat-wallpaper-mint {
+ background-color: #eaf8f4;
+ background-image:
+ radial-gradient(ellipse at 8% 9%, rgba(52, 211, 153, .34), transparent 46%),
+ radial-gradient(ellipse at 92% 10%, rgba(34, 211, 238, .28), transparent 44%),
+ radial-gradient(ellipse at 10% 92%, rgba(163, 230, 53, .22), transparent 43%),
+ radial-gradient(ellipse at 90% 90%, rgba(96, 165, 250, .3), transparent 47%),
+ url('/chat-pattern.svg'),
+ linear-gradient(145deg, rgba(237, 253, 247, .92), rgba(229, 244, 255, .9));
+}
+
+.telegram-bubble-in {
+ border: 1px solid rgba(226, 232, 240, .96);
+ background: rgba(255, 255, 255, .97);
+ box-shadow: 0 2px 5px rgba(15, 23, 42, .06), 0 10px 24px -18px rgba(15, 23, 42, .22);
+}
+
+.telegram-bubble-in::before {
+ content: '';
+ position: absolute;
+ bottom: -1px;
+ left: -7px;
+ width: 12px;
+ height: 14px;
+ background: rgba(255, 255, 255, .97);
+ clip-path: polygon(100% 0, 0 100%, 100% 100%);
+}
+
+.telegram-bubble-out {
+ border: 1px solid rgba(147, 197, 253, .75);
+ background: linear-gradient(135deg, #dbeafe 0%, #e0f2fe 52%, #eff6ff 100%);
+ box-shadow: 0 3px 7px rgba(37, 99, 235, .1), 0 12px 28px -18px rgba(37, 99, 235, .38);
+}
+
+.telegram-bubble-out::before {
+ content: '';
+ position: absolute;
+ right: -7px;
+ bottom: -1px;
+ width: 12px;
+ height: 14px;
+ background: #e8f3ff;
+ clip-path: polygon(0 0, 0 100%, 100% 100%);
+}
+
+.group:hover .telegram-bubble-in {
+ box-shadow: 0 3px 8px rgba(15, 23, 42, .08), 0 14px 30px -20px rgba(15, 23, 42, .28);
+}
+
+.group:hover .telegram-bubble-out {
+ box-shadow: 0 4px 10px rgba(37, 99, 235, .13), 0 16px 32px -20px rgba(37, 99, 235, .45);
}
@keyframes message-in-left {
@@ -85,12 +187,55 @@
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}
+@keyframes conversation-select {
+ 0% {
+ transform: translate3d(0, 2px, 0) scale(.988);
+ box-shadow: 0 0 0 rgba(37, 99, 235, 0);
+ }
+ 58% {
+ transform: translate3d(0, -1px, 0) scale(1.003);
+ box-shadow: 0 12px 28px -15px rgba(37, 99, 235, .48);
+ }
+ 100% {
+ transform: translate3d(0, 0, 0) scale(1);
+ box-shadow: 0 9px 24px -15px rgba(37, 99, 235, .52), 0 0 0 1px rgba(96, 165, 250, .1);
+ }
+}
+
+@keyframes conversation-sheen {
+ from { transform: translate3d(-170%, 0, 0) skewX(-18deg); opacity: 0; }
+ 18% { opacity: .7; }
+ to { transform: translate3d(470%, 0, 0) skewX(-18deg); opacity: 0; }
+}
+
+.conversation-selected {
+ animation: conversation-select .42s cubic-bezier(.2, .82, .2, 1) both;
+}
+
+.conversation-selected::after {
+ content: '';
+ pointer-events: none;
+ position: absolute;
+ inset-block: 0;
+ left: 0;
+ width: 24%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .78), transparent);
+ animation: conversation-sheen .62s cubic-bezier(.2, .7, .2, 1) .04s both;
+}
+
@layer utilities {
.animate-message-in-left { animation: message-in-left .34s cubic-bezier(.2,.8,.2,1) both; }
.animate-message-in-right { animation: message-in-right .34s cubic-bezier(.2,.8,.2,1) both; }
.animate-incoming-notice { animation: incoming-notice .42s cubic-bezier(.2,.9,.2,1) both; }
}
+@media (prefers-reduced-motion: reduce) {
+ .conversation-selected,
+ .conversation-selected::after {
+ animation: none;
+ }
+}
+
/* RTL 微调 */
[dir='rtl'] .rtl-flip {
transform: scaleX(-1);
diff --git a/src/types/ai.ts b/src/types/ai.ts
index cafe901..eeb3283 100644
--- a/src/types/ai.ts
+++ b/src/types/ai.ts
@@ -8,6 +8,7 @@ export interface AiReplySuggestion {
id: string
style: ReplyStyle
content: string
+ translationCn: string
targetLang: string
reason: string
knowledgeRefs: string[]
@@ -40,6 +41,18 @@ export interface TranslateResult {
translated: string
detectedLang: string
terms: { source: string; target: string }[]
+ historyItem?: TranslationHistoryItem
+}
+
+export interface TranslationHistoryItem {
+ id: string
+ sourceLang: string
+ targetLang: string
+ originalText: string
+ translatedText: string
+ detectedLang: string
+ favorite: boolean
+ createdAt: string
}
export type AiTextAction = 'polish' | 'translate' | 'shorten' | 'expand' | 'tone'
diff --git a/src/types/crm.ts b/src/types/crm.ts
index cd8618c..c989af7 100644
--- a/src/types/crm.ts
+++ b/src/types/crm.ts
@@ -77,6 +77,7 @@ export interface Customer {
telegramUserId?: string
avatarUrl?: string
telegramPhotoFileId?: number
+ telegramChatType?: string
}
export interface CustomerFilters {
diff --git a/src/types/messaging.ts b/src/types/messaging.ts
index 40ec8ec..85e6c7e 100644
--- a/src/types/messaging.ts
+++ b/src/types/messaging.ts
@@ -28,6 +28,8 @@ export interface Message {
type: MessageType
originalText: string
translatedText?: string
+ /** 发出消息的中文客服备注,不会发送到外部渠道 */
+ translationCn?: string
translationStatus?: 'pending' | 'completed' | 'failed'
translatedAt?: string
originalLang: string
@@ -43,6 +45,16 @@ export interface Message {
attachmentMimeType?: string
attachmentSize?: number
stickerEmoji?: string
+ emailUid?: number
+ emailMessageId?: string
+ emailSubject?: string
+ emailAttachments?: Array<{
+ id: string
+ name: string
+ mimeType: string
+ size: number
+ available: boolean
+ }>
}
export interface Conversation {
@@ -58,6 +70,16 @@ export interface Conversation {
lastMessage?: Message
summary: string
updatedAt: string
+ emailSubject?: string
+ channelId?: string
+ telegramChatId?: string
+ telegramChatType?: string
+ isPinned?: boolean
+ archived?: boolean
+ mutedUntil?: string | null
+ markedUnread?: boolean
+ isBlocked?: boolean
+ leftAt?: string
}
export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
@@ -72,7 +94,18 @@ export interface ChannelConnection {
todayMessages: number
errorMessage?: string
autoReply: boolean
+ authState?: string
defaultOwnerId?: string
+ displayName?: string
+ imapHost?: string
+ imapPort?: number
+ imapSecure?: boolean
+ imapFolder?: string
+ smtpHost?: string
+ smtpPort?: number
+ smtpSecure?: boolean
+ syncDays?: number
+ lastVerifiedAt?: string
}
export interface ChannelSyncLog {
@@ -87,6 +120,8 @@ export interface ChannelSyncLog {
/** 发送消息请求 */
export interface SendMessagePayload {
text: string
+ /** 已确认的中文参考译文,仅在内部消息记录中保存 */
+ translationCn?: string
replyToId?: string
replyPreview?: string
type?: MessageType
diff --git a/src/types/org.ts b/src/types/org.ts
index 85f3225..4adc9f0 100644
--- a/src/types/org.ts
+++ b/src/types/org.ts
@@ -138,7 +138,7 @@ export interface SystemSettings {
export interface AppNotification {
id: string
- type: 'inquiry' | 'risk' | 'task' | 'channel' | 'system'
+ type: 'inquiry' | 'message' | 'risk' | 'task' | 'channel' | 'system'
title: string
description: string
createdAt: string
diff --git a/tailwind.config.js b/tailwind.config.js
index 2ed88ea..22fac50 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -13,16 +13,16 @@ export default {
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
- 50: '#effaf7',
- 100: '#d7f2ec',
- 200: '#b2e4da',
- 300: '#83cfc2',
- 400: '#52b3a5',
- 500: '#32998c',
- 600: '#247b71',
- 700: '#1f635c',
- 800: '#1c504b',
- 900: '#1b423f',
+ 50: '#eff6ff',
+ 100: '#dbeafe',
+ 200: '#bfdbfe',
+ 300: '#93c5fd',
+ 400: '#60a5fa',
+ 500: '#3b82f6',
+ 600: '#2563eb',
+ 700: '#1d4ed8',
+ 800: '#1e40af',
+ 900: '#1e3a8a',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
diff --git a/tests/e2e/inbox-ai-live.spec.ts b/tests/e2e/inbox-ai-live.spec.ts
index 652df1f..5a191c0 100644
--- a/tests/e2e/inbox-ai-live.spec.ts
+++ b/tests/e2e/inbox-ai-live.spec.ts
@@ -32,6 +32,10 @@ test.describe('真实收件箱 AI 交互', () => {
await expect(customerBubble.first()).toHaveClass(/animate-message-in-left/)
const agentBubble = page.locator('[data-testid="message-bubble"][data-sender="agent"]')
await expect(agentBubble.first()).toHaveClass(/animate-message-in-right/)
+ const outgoingChineseNotes = page.getByTestId('outgoing-chinese-note')
+ await expect.poll(() => outgoingChineseNotes.count()).toBeGreaterThan(0)
+ await expect(outgoingChineseNotes.first()).toContainText('中文备注 · 仅内部可见')
+ await expect(outgoingChineseNotes.first()).toContainText(/[\u3400-\u9fff]/u)
await page.getByTitle('客户洞察').click()
await expect(page.getByText('成交概率', { exact: true })).toBeVisible()
await expect(page.getByText('分析可信度', { exact: true })).toBeVisible()
@@ -49,6 +53,10 @@ test.describe('真实收件箱 AI 交互', () => {
await page.getByRole('button', { name: '生成回复建议', exact: true }).click()
const adoptButtons = page.getByRole('button', { name: '采用', exact: true })
await expect(adoptButtons).toHaveCount(3, { timeout: 30_000 })
+ const translationPreviews = page.getByTestId('ai-reply-translation')
+ await expect(translationPreviews).toHaveCount(3)
+ await expect(translationPreviews.first()).toContainText(/[\u3400-\u9fff]/u)
+ await expect(translationPreviews.first()).toContainText('中文参考 · 不会发送')
await adoptButtons.first().click()
const composer = page.getByPlaceholder('输入回复内容,Enter 发送,Shift+Enter 换行')
diff --git a/tests/e2e/lingxi.spec.ts b/tests/e2e/lingxi.spec.ts
index ae346ce..d505efa 100644
--- a/tests/e2e/lingxi.spec.ts
+++ b/tests/e2e/lingxi.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test, type Page } from '@playwright/test'
+import { expect, test, type Page, type WebSocketRoute } from '@playwright/test'
const account = process.env.E2E_ACCOUNT || 'e2e-1785008967@local.test'
const password = process.env.E2E_PASSWORD || 'Lingxi-E2E-2026!'
@@ -20,13 +20,38 @@ test('Telegram 手机号验证码和二步密码登录向导', async ({ page, re
})
expect(response.ok()).toBeTruthy()
await login(page, loginAccount, loginPassword)
- await page.goto('/channels')
- await page.getByRole('button', { name: '连接', exact: true }).click()
+ await page.goto('/channels?setup=telegram')
+ await expect(page.getByTestId('telegram-onboarding')).toContainText('先绑定 Telegram')
await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
- await expect(page.getByText('Telegram 验证码')).toHaveCount(0)
+ await expect(page.getByLabel('Telegram 验证码', { exact: true })).toHaveCount(0)
+ const sendTelegramCode = page.getByRole('button', { name: '发送 Telegram 验证码' })
+ await expect(sendTelegramCode).toBeVisible()
+ await sendTelegramCode.click()
+ await expect(page.getByText('请输入国际格式手机号,例如 +8613800138000')).toBeVisible()
+ await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
+ await expect(page.getByText('每个企业独立绑定账号')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('telegram-login-wizard.png'), fullPage: true })
})
+test('企业邮箱 IMAP 和 SMTP 连接向导', async ({ page, request }, testInfo) => {
+ const suffix = `${Date.now()}-${testInfo.project.name}`
+ const loginAccount = `email-e2e-${suffix}@local.test`
+ const loginPassword = 'Lingxi-Email-2026!'
+ const response = await request.post('/api/auth/register', {
+ data: { name: 'Email E2E', account: loginAccount, password: loginPassword, company: 'Email E2E', industry: '跨境电商', teamSize: '1-10人' },
+ })
+ expect(response.ok()).toBeTruthy()
+ await login(page, loginAccount, loginPassword)
+ await page.goto('/channels')
+ const emailCard = page.getByTestId('channel-card-email')
+ await emailCard.getByRole('button', { name: '连接', exact: true }).click()
+ await expect(page.getByText('IMAP 收信 + SMTP 发信', { exact: true })).toBeVisible()
+ await expect(page.getByPlaceholder('sales@company.com')).toBeVisible()
+ await expect(page.getByPlaceholder('仅提交到本地服务端验证')).toHaveAttribute('type', 'password')
+ await expect(page.getByRole('button', { name: '验证并连接邮箱' })).toBeVisible()
+ await page.screenshot({ path: testInfo.outputPath('email-connection-wizard.png'), fullPage: true })
+})
+
test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
await login(page)
await expect(page).toHaveURL(/\/dashboard$/)
@@ -37,11 +62,115 @@ test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
await page.screenshot({ path: testInfo.outputPath('channels.png'), fullPage: true })
await page.goto('/inbox')
- await page.getByTestId('conversation-item').first().click()
+ await page.getByRole('button', { name: '添加 Telegram 用户或群组' }).click()
+ await expect(page.getByRole('dialog')).toContainText('添加 Telegram 会话')
+ await expect(page.getByLabel('用户名、手机号或用户 ID')).toBeVisible()
+ await page.getByRole('button', { name: '群组/频道', exact: true }).click()
+ await expect(page.getByLabel('群组/频道用户名或 Chat ID')).toBeVisible()
+ await page.getByRole('button', { name: '邀请链接', exact: true }).click()
+ await expect(page.getByLabel('Telegram 邀请链接')).toBeVisible()
+ await page.getByRole('button', { name: '关闭', exact: true }).click()
+ const privateConversation = page.locator(
+ '[data-testid="conversation-item"][data-channel="telegram"]:not([data-telegram-chat-type="channel"])',
+ ).first()
+ await privateConversation.click()
+ await expect(page.getByTestId('message-content')).toHaveClass(/justify-end/)
+ const languageSwitcher = page.getByRole('radiogroup', { name: '消息显示语言' })
+ const originalMode = languageSwitcher.getByRole('radio', { name: '原文', exact: true })
+ const translatedMode = languageSwitcher.getByRole('radio', { name: '译文', exact: true })
+ await originalMode.click()
+ await expect(originalMode).toHaveAttribute('aria-checked', 'true')
+ await translatedMode.click()
+ await expect(translatedMode).toHaveAttribute('aria-checked', 'true')
+ await expect.poll(() => page.evaluate(() => localStorage.getItem('lingxi-message-view-mode'))).toBe('translated')
await expect(page.locator('input[type="file"]')).toHaveCount(4)
+ const outgoingSurface = page.locator('[data-testid="message-surface"][data-direction="outgoing"]')
+ await expect(outgoingSurface.first()).toHaveClass(/telegram-bubble-out/)
+ const incomingSurface = page.locator('[data-testid="message-surface"][data-direction="incoming"]')
+ if (await incomingSurface.count()) await expect(incomingSurface.first()).toHaveClass(/telegram-bubble-in/)
+ await expect(privateConversation).toHaveClass(/conversation-selected/)
+ await expect(page.getByTestId('conversation-list-panel')).toHaveClass(/border-slate-300/)
+ await expect(page.getByTestId('chat-panel')).toHaveClass(/bg-\[#eaf2f9\]/)
+ await expect(page.getByTestId('conversation-insight-bar').locator('img')).toHaveCount(0)
+ await expect(page.getByTestId('conversation-insight-bar')).toContainText('AI 客户洞察')
+ await page.getByRole('button', { name: 'Telegram 会话操作' }).click()
+ await expect(page.getByText(/置顶会话|取消置顶/)).toBeVisible()
+ await expect(page.getByText(/归档会话|移出归档/)).toBeVisible()
+ await expect(page.getByText(/静音 8 小时|恢复通知/)).toBeVisible()
+ await expect(page.getByText('清空消息', { exact: true })).toBeVisible()
+ await expect(page.getByText('删除会话', { exact: true })).toBeVisible()
+ await page.keyboard.press('Escape')
+ await page.getByRole('button', { name: '切换聊天背景' }).click()
+ await page.getByText('极光紫', { exact: true }).click()
+ await expect(page.getByTestId('message-list')).toHaveClass(/chat-wallpaper-aurora/)
+ await page.getByRole('button', { name: '切换聊天背景' }).click()
+ await page.getByText('日落橙', { exact: true }).click()
+ await expect(page.getByTestId('message-list')).toHaveClass(/chat-wallpaper-sunset/)
+ await page.getByRole('button', { name: '切换聊天背景' }).click()
+ await page.getByText('薄荷绿', { exact: true }).click()
+ await expect(page.getByTestId('message-list')).toHaveClass(/chat-wallpaper-mint/)
+ await expect(page.getByPlaceholder('输入回复内容,Enter 发送,Shift+Enter 换行')).toHaveClass(/min-h-\[86px\]/)
await page.screenshot({ path: testInfo.outputPath('inbox.png'), fullPage: true })
})
+test('mobile conversation swipe reveals Telegram actions', async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'mobile-chromium')
+ await login(page)
+ await page.goto('/inbox')
+ const item = page.locator('[data-testid="conversation-item"][data-channel="telegram"]').first()
+ await expect(item).toBeVisible()
+ await item.dispatchEvent('touchstart', { touches: [{ identifier: 1, clientX: 320, clientY: 120 }] })
+ await item.dispatchEvent('touchmove', { touches: [{ identifier: 1, clientX: 120, clientY: 120 }] })
+ await page.waitForTimeout(100)
+ await item.dispatchEvent('touchend', { changedTouches: [{ identifier: 1, clientX: 120, clientY: 120 }] })
+ const actions = item.locator('xpath=..').getByTestId('conversation-swipe-actions')
+ await expect(actions).toHaveClass(/opacity-100/)
+ await expect(actions.getByRole('button', { name: '删除' })).toBeVisible()
+})
+
+test('conversation list reorders immediately from WebSocket events', async ({ page }) => {
+ const sockets: WebSocketRoute[] = []
+ await page.routeWebSocket(/\/api\/ws/, (socket) => {
+ sockets.push(socket)
+ socket.send(JSON.stringify({ type: 'connected', at: new Date().toISOString() }))
+ })
+ await login(page)
+ await page.goto('/inbox')
+ const items = page.getByTestId('conversation-item')
+ await expect(items.nth(1)).toBeVisible()
+ await expect.poll(() => sockets.length).toBeGreaterThanOrEqual(2)
+
+ const target = items.nth(1)
+ const targetId = await target.getAttribute('data-conversation-id')
+ expect(targetId).toBeTruthy()
+ const message = {
+ id: `realtime-${Date.now()}`,
+ conversationId: targetId,
+ sender: 'customer',
+ type: 'text',
+ originalText: 'Realtime list regression',
+ originalLang: 'en',
+ status: 'delivered',
+ createdAt: new Date(Date.now() + 60_000).toISOString(),
+ }
+ sockets.at(-1)!.send(JSON.stringify({
+ type: 'message.received',
+ conversationId: targetId,
+ message,
+ conversation: {
+ id: targetId,
+ unreadCount: 7,
+ status: 'pending',
+ lastMessage: message,
+ updatedAt: message.createdAt,
+ },
+ }))
+
+ await expect(items.first()).toHaveAttribute('data-conversation-id', targetId!)
+ await expect(items.first()).toContainText('Realtime list regression')
+ await expect(items.first()).toContainText('7')
+})
+
test('知识文件真实上传解析', async ({ page }, testInfo) => {
await login(page)
await page.goto('/knowledge')
@@ -56,6 +185,22 @@ test('知识文件真实上传解析', async ({ page }, testInfo) => {
await page.screenshot({ path: testInfo.outputPath('knowledge-uploaded.png'), fullPage: true })
})
+test('客户画像雷达与采购情报可视化', async ({ page }, testInfo) => {
+ await login(page)
+ await page.goto('/customers')
+ const rows = page.getByTestId('customer-row')
+ await expect(rows.first()).toBeVisible()
+ await rows.first().getByRole('cell').nth(1).click()
+ await expect(page.getByText('客户价值雷达', { exact: true })).toBeVisible()
+ await expect(page.getByText('AI 客户洞察', { exact: true })).toBeVisible()
+ await expect(page.getByText('采购情报', { exact: true })).toBeVisible()
+ await expect(page.getByText('标签画像', { exact: true })).toBeVisible()
+ const semanticTags = page.locator('[data-tag-category]')
+ if (await semanticTags.count()) await expect(semanticTags.first()).toBeVisible()
+ else await expect(page.getByText('暂无标签,AI 将在收到有效客户消息后自动补全。')).toBeVisible()
+ await page.screenshot({ path: testInfo.outputPath('customer-profile-radar.png'), fullPage: true })
+})
+
test('团队邀请和支付页不显示虚假成功', async ({ page }, testInfo) => {
await login(page)
await page.goto('/team')
|