feat(history): add multi-account and WhatsApp foundation

This commit is contained in:
wmq
2026-07-30 00:14:32 +08:00
parent 7f09132fad
commit 519af888e5
43 changed files with 7484 additions and 290 deletions
+6
View File
@@ -15,6 +15,12 @@ TELEGRAM_GATEWAY_SERVICE_EMAIL=replace-with-gateway-service-email
TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password
TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id
TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret
# 普通 WhatsApp 账户通过“关联设备”接入。两个值均至少 32 位且必须彼此不同。
WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters
WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret
WHATSAPP_LOG_LEVEL=info
PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
APP_PUBLIC_URL=http://127.0.0.1:8088
+22
View File
@@ -16,6 +16,21 @@ services:
retries: 10
restart: unless-stopped
whatsapp-gateway:
build:
context: ./whatsapp-gateway
environment:
PORT: 3200
DATA_DIR: /data
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info}
volumes:
- lingxi_whatsapp_data:/data
dns:
- 1.1.1.1
- 8.8.8.8
restart: unless-stopped
api:
build:
context: ./server
@@ -33,6 +48,10 @@ services:
TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required}
TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-}
TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required}
WHATSAPP_GATEWAY_URL: http://whatsapp-gateway:3200
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
WHATSAPP_WEBHOOK_SECRET: ${WHATSAPP_WEBHOOK_SECRET:?WHATSAPP_WEBHOOK_SECRET is required}
WHATSAPP_WEBHOOK_BASE_URL: ${WHATSAPP_WEBHOOK_BASE_URL:-http://api:3100}
PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-http://host.docker.internal:8088}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088}
SMTP_HOST: ${SMTP_HOST:-}
@@ -56,6 +75,8 @@ services:
depends_on:
postgres:
condition: service_healthy
whatsapp-gateway:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 10s
@@ -77,3 +98,4 @@ services:
volumes:
lingxi_pgdata:
lingxi_whatsapp_data:
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"version": "1.0.0",
"type": "module",
"description": "灵犀跨境 - Telegram 跨境 AI 客户经营平台",
"description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
+1306 -8
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
const base = () => (process.env.WHATSAPP_GATEWAY_URL || 'http://whatsapp-gateway:3200').replace(/\/$/, '')
export async function whatsappRequest(path, options = {}) {
const token = String(process.env.WHATSAPP_GATEWAY_TOKEN || '')
if (token.length < 24) {
throw Object.assign(new Error('WhatsApp 网关令牌尚未配置'), { status: 503 })
}
const response = await fetch(`${base()}${path}`, {
...options,
headers: {
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
authorization: `Bearer ${token}`,
...(options.headers || {}),
},
})
const raw = await response.text()
let result
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
if (!response.ok) {
throw Object.assign(
new Error(result?.message || `WhatsApp 网关请求失败 (${response.status})`),
{ status: response.status, data: result?.data },
)
}
return result?.data ?? result
}
export function postWhatsApp(path, body) {
return whatsappRequest(path, { method: 'POST', body: JSON.stringify(body) })
}
export function putWhatsApp(path, body) {
return whatsappRequest(path, { method: 'PUT', body: JSON.stringify(body) })
}
export function uploadWhatsApp(path, formData) {
return whatsappRequest(path, { method: 'POST', body: formData })
}
+3 -1
View File
@@ -50,5 +50,7 @@ test('Email 渠道凭据使用认证加密且可正确解密', () => {
assert.notEqual(encrypted, 'app-password-123')
assert.match(encrypted, /^v1:/)
assert.equal(decryptEmailPassword(encrypted), 'app-password-123')
assert.throws(() => decryptEmailPassword(`${encrypted.slice(0, -1)}x`))
const tampered = encrypted.split(':')
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
assert.throws(() => decryptEmailPassword(tampered.join(':')))
})
+55 -7
View File
@@ -1,28 +1,76 @@
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
import { useAuthStore } from '@/app/store/auth'
import { createBrowserRouter, Navigate, RouterProvider, useRouteError } from 'react-router-dom'
import { isSessionTokenExpired, useAuthStore } from '@/app/store/auth'
import { AppLayout } from '@/components/layout/AppLayout'
import type { ReactNode } from 'react'
import { useEffect, type ReactNode } from 'react'
import { LoginPage } from '@/pages/auth/LoginPage'
import { RegisterPage } from '@/pages/auth/RegisterPage'
function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
if (!token) return <Navigate to="/login" replace />
const logout = useAuthStore((s) => s.logout)
const expired = isSessionTokenExpired(token)
useEffect(() => {
if (token && expired) logout()
}, [expired, logout, token])
if (expired) return <Navigate to="/login?reason=session-expired" replace />
return <>{children}</>
}
function GuestOnly({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
if (token) return <Navigate to="/dashboard" replace />
const logout = useAuthStore((s) => s.logout)
const expired = Boolean(token) && isSessionTokenExpired(token)
useEffect(() => {
if (expired) logout()
}, [expired, logout])
if (token && !expired) return <Navigate to="/dashboard" replace />
return <>{children}</>
}
function RouteErrorFallback() {
const error = useRouteError()
useEffect(() => {
console.error('Route render failed', error)
}, [error])
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-6">
<section className="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-xl shadow-slate-200/60">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-xl text-blue-600">
!
</div>
<h1 className="mt-5 text-xl font-semibold tracking-tight text-slate-950">页面暂时无法显示</h1>
<p className="mt-2 text-sm leading-6 text-slate-500">
当前页面遇到异常。你可以重新加载;如果问题仍然存在,请返回工作台后重试。
</p>
<div className="mt-6 flex justify-center gap-3">
<button
type="button"
onClick={() => window.location.reload()}
className="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
>
重新加载
</button>
<a
href="/dashboard"
className="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
>
返回工作台
</a>
</div>
</section>
</main>
)
}
const router = createBrowserRouter([
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> },
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> },
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
{
path: '/',
errorElement: <RouteErrorFallback />,
element: (
<RequireAuth>
<AppLayout />
+21 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { AuthSession, Tenant, User } from '@/types'
import type { RoleKey } from '@/config/roles'
import { setTokenGetter } from '@/services/api/client'
import { setTokenGetter, setUnauthorizedHandler } from '@/services/api/client'
interface AuthState {
token: string | null
@@ -32,6 +32,26 @@ export const useAuthStore = create<AuthState>()(
// 让 API client 能读取 token(真实后端模式使用)
setTokenGetter(() => useAuthStore.getState().token)
setUnauthorizedHandler(() => {
useAuthStore.getState().logout()
if (!window.location.pathname.startsWith('/login')) {
window.location.replace('/login?reason=session-expired')
}
})
export function isSessionTokenExpired(token: string | null): boolean {
if (!token) return true
try {
const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=')
const payload = JSON.parse(window.atob(padded)) as {
exp?: number
}
return !payload.exp || payload.exp * 1_000 <= Date.now()
} catch {
return true
}
}
export function useCurrentRole(): RoleKey {
return useAuthStore((s) => s.user?.role ?? 'viewer')
+1 -1
View File
@@ -8,7 +8,7 @@ export function LiveDataBadge({ className, text = '实时数据' }: { className?
'inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700',
className,
)}
title="指标由当前企业真实业务数据实时计算"
title="指标实时更新"
>
<Radio className="h-3 w-3" />
{text}
@@ -37,7 +37,7 @@ export function profileRadarData(customer: Customer) {
return [
{ dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' },
{ dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: 'AI 综合预测成交概率' },
{ dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: '根据当前客户资料和会话信号估算' },
{ dimension: '决策能力', score: qualitativeScore(profile.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' },
{ dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' },
{ dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' },
@@ -80,7 +80,7 @@ export function CustomerProfileRadar({
</span>
<div>
<p className="text-sm font-semibold tracking-tight text-slate-950">客户价值雷达</p>
<p className="mt-0.5 text-[11px] text-slate-500">AI 六维购买力画像 · 随会话实时更新</p>
<p className="mt-0.5 text-[11px] text-slate-500">基于当前客户资料和会话信号</p>
</div>
</div>
<span className={cn('mt-3 inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold', levelTone)}>
@@ -126,7 +126,7 @@ export function CustomerProfileRadar({
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
<Tooltip
formatter={(value: number) => [`${value} / 100`, '画像指数']}
labelFormatter={(label) => `${label} · AI 评估`}
labelFormatter={(label) => `${label} · 参考值`}
contentStyle={{
borderRadius: 14,
border: '1px solid #dbeafe',
@@ -135,7 +135,7 @@ export function CustomerProfileRadar({
}}
/>
<Radar
name="客户画像光晕"
name="客户画像"
dataKey="score"
isAnimationActive={false}
stroke="#60a5fa"
+4 -4
View File
@@ -33,7 +33,7 @@ export function AiReplies({
mutationFn: () => generateReplies(conversationId),
onSuccess: (data) => {
setItems(Array.isArray(data) ? data : [])
toast.success('AI 回复建议已生成', '根据客户画像、知识库和历史成功回复综合生成')
toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复')
},
onError: (e) => toast.error(errorMessage(e)),
})
@@ -43,7 +43,7 @@ export function AiReplies({
<div className="mb-3 flex items-center justify-between">
<h4 className="flex items-center gap-2 text-xs font-semibold text-slate-700">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary-50"><Sparkles className="h-3.5 w-3.5 text-primary" /></span>
AI 智能回复
回复建议
</h4>
<div className="flex items-center gap-1">
<Button
@@ -61,7 +61,7 @@ export function AiReplies({
'生成回复建议'
)}
</Button>
<Button variant="ghost" size="icon-sm" onClick={onClose} title="关闭 AI 智能回复">
<Button variant="ghost" size="icon-sm" onClick={onClose} title="关闭回复建议">
<X className="h-4 w-4" />
</Button>
</div>
@@ -169,7 +169,7 @@ export function AiReplies({
{!items && !mutation.isPending && (
<div className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5">
<p className="text-xs text-slate-500">结合客户画像、知识库和历史沟通生成三种策略</p>
<p className="text-xs text-slate-500">生成三种回复建议</p>
<span className="hidden text-[10px] font-medium text-primary sm:inline">不会自动发送</span>
</div>
)}
+6 -1
View File
@@ -75,7 +75,12 @@ export function Composer({
setSending(true)
try {
await onMedia(Array.from(files), kind)
toast.success(`${files.length} 个文件已发送到${channel === 'email' ? '客户邮箱' : ' Telegram'}`)
const destination = channel === 'email'
? '客户邮箱'
: channel === 'whatsapp'
? ' WhatsApp'
: ' Telegram'
toast.success(`${files.length} 个文件已发送到${destination}`)
} catch {
toast.error('媒体发送失败')
} finally {
@@ -23,7 +23,7 @@ export function ConversationInsightBar({
onOpenProfile: () => void
}) {
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
const summary = customer.aiProfile?.summary || conversation.summary || 'AI 正在根据会话完善客户画像'
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像'
return (
<div
@@ -36,7 +36,7 @@ export function ConversationInsightBar({
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
AI 客户洞察
客户洞察
</span>
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
</div>
+9 -6
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react'
import { Eraser, Mail, Pin, Plus, Search, Send, ShieldAlert, Trash2 } from 'lucide-react'
import { Eraser, Mail, MessageCircle, Pin, Plus, Search, Send, ShieldAlert, Trash2 } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { getTeam } from '@/services/api/org'
import type { ConversationListItem } from '@/services/api/conversations'
@@ -26,7 +26,7 @@ const FILTERS = [
]
export function ConversationList({
items,
items = [],
loading,
activeId,
onSelect,
@@ -75,8 +75,8 @@ export function ConversationList({
<button
type="button"
onClick={onAdd}
title="添加 Telegram 用户或群组"
aria-label="添加 Telegram 用户或群组"
title="添加联系人或群组"
aria-label="添加联系人或群组"
className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-white shadow-sm shadow-primary-200 transition hover:-translate-y-0.5 hover:bg-primary-700"
>
<Plus className="h-4 w-4" />
@@ -96,6 +96,7 @@ export function ConversationList({
{[
{ value: '', label: '全部', icon: null },
{ value: 'telegram', label: 'Telegram', icon: Send },
{ value: 'whatsapp', label: 'WhatsApp', icon: MessageCircle },
{ value: 'email', label: 'Email', icon: Mail },
].map((item) => (
<button
@@ -153,9 +154,10 @@ export function ConversationList({
const active = item.id === activeId
const intent = intentLevelMeta(item.intentLevel)
const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel'
const supportsSwipeActions = item.channel === 'telegram' || item.channel === 'whatsapp'
return (
<div key={item.id} className="relative mb-0.5 overflow-hidden rounded-xl">
{item.channel === 'telegram' && (
{supportsSwipeActions && (
<div
data-testid="conversation-swipe-actions"
aria-hidden={swipedId !== item.id && touchStart.current?.id !== item.id}
@@ -189,8 +191,9 @@ export function ConversationList({
data-conversation-id={item.id}
data-channel={item.channel}
data-telegram-chat-type={item.telegramChatType || ''}
data-last-sender={item.lastMessage?.sender || ''}
onTouchStart={(event) => {
if (item.channel !== 'telegram') return
if (!supportsSwipeActions) return
const touch = event.touches[0]
touchStart.current = { x: touch.clientX, y: touch.clientY, id: item.id }
if (swipedId !== item.id) setDragOffset(0)
+6 -6
View File
@@ -70,7 +70,7 @@ export function InsightPanel({
conversationId: conversation.id,
ownerId: user?.id || '',
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
note: `由 AI 客户洞察建议创建:${title}`,
note: `由客户洞察建议创建:${title}`,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
@@ -84,7 +84,7 @@ export function InsightPanel({
try {
const result = await analyzeConversation(conversation.id)
setAnalysis(result.summary)
toast.success('AI 分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
toast.success('会话分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
} catch {
toast.error('分析失败,请稍后重试')
} finally { setAnalyzing(false) }
@@ -180,8 +180,8 @@ export function InsightPanel({
<div className="rounded-2xl border border-slate-200/80 bg-white p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-semibold tracking-tight text-slate-900">AI 洞察摘要</p>
<p className="text-[11px] text-slate-400">基于完整对话实时更新</p>
<p className="text-sm font-semibold tracking-tight text-slate-900">会话摘要</p>
<p className="text-[11px] text-slate-400">根据已同步的会话内容生成</p>
</div>
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
@@ -193,7 +193,7 @@ export function InsightPanel({
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
</div>
) : (
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || 'AI 正在根据会话完善客户画像。'}</p>
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多客户消息后将补充客户画像。'}</p>
)}
{/* AI 提取关键信息 */}
{Object.values(customer.aiProfile || {}).some(Boolean) && (
@@ -240,7 +240,7 @@ export function InsightPanel({
{/* 推荐动作 */}
<div className="p-4">
<h4 className="text-xs font-semibold text-slate-500">AI 推荐下一步</h4>
<h4 className="text-xs font-semibold text-slate-500">下一步建议</h4>
<div className="mt-2 space-y-2">
{customer.aiProfile.nextActions.map((a) => (
<button
+63 -20
View File
@@ -19,9 +19,11 @@ import {
Download,
FileText,
LoaderCircle,
SmilePlus,
Trash2,
} from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import type { Message } from '@/types'
import type { ChannelType, Message } from '@/types'
import { cn } from '@/utils/cn'
import { toast } from '@/app/store/toast'
import { formatTime } from '@/utils/format'
@@ -82,8 +84,11 @@ export function MessageBubble({
onConvertToTask,
onAddToKnowledge,
onAddNote,
onReact,
onDelete,
customerName,
customerAvatar,
channel,
}: {
msg: Message
viewMode: ViewMode
@@ -92,33 +97,43 @@ export function MessageBubble({
onConvertToTask: () => void
onAddToKnowledge: () => void
onAddNote: () => void
onReact?: (emoji: string) => void
onDelete?: () => void
customerName?: string
customerAvatar?: string
channel?: ChannelType
}) {
const [showActions, setShowActions] = useState(false)
const [translationExpanded, setTranslationExpanded] = useState(false)
const isMe = msg.sender === 'agent'
const isSystem = msg.sender === 'system'
const originalText = String(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))
rawTranslation.length > (originalText.length <= 20 ? 160 : Math.max(500, originalText.length * 8))
const translatedText = translationCorrupted ? '' : rawTranslation
const hasTranslation = translatedText && translatedText !== msg.originalText
const hasTranslation = translatedText && translatedText !== originalText
const outgoingChineseNote = isMe
? String(
msg.translationCn ||
(hasTranslation && /[\u3400-\u9fff]/u.test(msg.originalText) ? msg.originalText : ''),
(hasTranslation && /[\u3400-\u9fff]/u.test(originalText) ? originalText : ''),
).trim()
: ''
const outgoingSentText = isMe && translatedText ? translatedText : msg.originalText
const outgoingSentText = isMe && translatedText ? translatedText : 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 providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram'
const isMediaMessage = ['image', 'audio', 'file'].includes(msg.type)
const hasProviderAttachment = Boolean(
msg.telegramFileId
|| (msg.whatsappMessageId && isMediaMessage),
)
const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId],
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId, msg.whatsappMessageId],
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
enabled: Boolean(msg.telegramFileId),
enabled: hasProviderAttachment,
staleTime: Infinity,
retry: 1,
})
@@ -126,7 +141,7 @@ export function MessageBubble({
if (isSystem) {
return (
<div className="flex justify-center py-2">
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{msg.originalText}</span>
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{originalText}</span>
</div>
)
}
@@ -156,10 +171,18 @@ export function MessageBubble({
)
const renderContent = () => {
if (msg.isDeleted) {
return (
<p className="flex items-center gap-1.5 text-xs italic text-slate-400">
<AlertCircle className="h-3.5 w-3.5" />
该消息已撤回
</p>
)
}
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>
return originalText ? (
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{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" />
@@ -167,8 +190,8 @@ export function MessageBubble({
</div>
)
}
if (!String(msg.originalText || '').trim()) {
return <p className="text-xs text-slate-500">Telegram 服务通知</p>
if (!originalText.trim()) {
return <p className="text-xs text-slate-500">{providerName} 服务通知</p>
}
if (
(viewMode === 'bilingual' || viewMode === 'translated') &&
@@ -178,7 +201,7 @@ export function MessageBubble({
) {
return (
<div className="space-y-2.5">
<p className="whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
<p className="whitespace-pre-wrap break-words text-sm leading-6">{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
@@ -239,7 +262,7 @@ export function MessageBubble({
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{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">
@@ -270,7 +293,7 @@ export function MessageBubble({
</span>
)}
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
{msg.originalText}
{originalText}
</p>
</div>
)
@@ -294,12 +317,18 @@ export function MessageBubble({
}
const moreActions = [
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } },
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { void navigator.clipboard.writeText(originalText); toast.success('已复制') } },
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
...(channel === 'whatsapp' && onReact
? [{ key: 'react', label: '👍 回应', icon: SmilePlus, onSelect: () => onReact('👍') }]
: []),
{ key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
{ key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge },
...(channel === 'whatsapp' && isMe && onDelete
? [{ key: 'delete', label: '撤回消息', icon: Trash2, danger: true, onSelect: onDelete }]
: []),
]
return (
@@ -315,7 +344,7 @@ export function MessageBubble({
>
{!isMe && (
<Avatar
name={customerName || msg.senderName || 'Telegram 用户'}
name={customerName || msg.senderName || `${providerName} 用户`}
src={customerAvatar}
size="xs"
className="order-1 mt-1"
@@ -348,9 +377,9 @@ export function MessageBubble({
</div>
)}
{renderContent()}
{msg.telegramFileId && attachmentLoading && (
{hasProviderAttachment && 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 文件…
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 {providerName} 文件…
</div>
)}
{attachment && msg.type === 'image' && (
@@ -378,7 +407,7 @@ export function MessageBubble({
<Download className="h-4 w-4 text-muted-foreground" />
</a>
)}
{msg.telegramFileId && attachmentError && (
{hasProviderAttachment && 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) => (
@@ -389,11 +418,25 @@ export function MessageBubble({
attachment={emailAttachment}
/>
))}
{Boolean(msg.reactions?.length) && (
<div className="mt-2 flex flex-wrap gap-1">
{msg.reactions?.map((reaction, index) => (
<span
key={`${reaction.actor}-${reaction.emoji}-${index}`}
title={reaction.fromMe ? '我' : reaction.actor}
className="inline-flex min-w-7 items-center justify-center rounded-full border border-blue-200 bg-blue-50/90 px-2 py-0.5 text-sm shadow-sm"
>
{reaction.emoji}
</span>
))}
</div>
)}
</div>
{/* 底栏:时间 + 状态 */}
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}>
<span>{formatTime(msg.createdAt)}</span>
{msg.editedAt && !msg.isDeleted && <span>已编辑</span>}
{isMe && statusIcon()}
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />}
{msg.internalNote && <StickyNote className="h-2.5 w-2.5 text-blue-500" />}
+3 -3
View File
@@ -57,7 +57,7 @@ export const PRICING_PLANS: PricingPlan[] = [
{
key: 'pro',
name: '专业版',
tagline: '5—20 人团队的 AI 经营中枢',
tagline: '多渠道客户沟通与团队协作',
priceNote: '¥49,800/年',
monthlyPrice: null,
yearlyPrice: 49800,
@@ -66,9 +66,9 @@ export const PRICING_PLANS: PricingPlan[] = [
features: [
'包含标准版全部能力',
'客户情感分析与意图预测',
'AI 智能回复建议(多策略)',
'多策略回复建议',
'风险预警与文化敏感检测',
'老板经营看板',
'业务数据看板',
],
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 },
},
+1 -1
View File
@@ -24,7 +24,7 @@ export const ROLE_META: Record<RoleKey, { label: string; description: string; co
admin: { label: '企业管理员', description: '管理企业、成员、权限、渠道、套餐与系统设置', color: 'bg-violet-100 text-violet-700' },
manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' },
sales: { label: '外贸业务员', description: '查看分配给自己的客户、消息与跟进任务', color: 'bg-teal-100 text-teal-700' },
service: { label: '客服人员', description: '处理跨渠道消息、翻译、AI回复与售后咨询', color: 'bg-amber-100 text-amber-700' },
service: { label: '客服人员', description: '处理跨渠道消息、翻译、回复建议与售后咨询', color: 'bg-amber-100 text-amber-700' },
viewer: { label: '只读成员', description: '仅可查看被授权的数据,不能编辑或发送消息', color: 'bg-gray-100 text-gray-600' },
}
+9 -9
View File
@@ -26,7 +26,7 @@ export function AnalyticsPage() {
<div className="p-4 md:p-6">
<PageHeader
title="数据分析中心"
description="企业经营数据全景 · 基于当前企业实时数据"
description="查看客户、消息、跟进与转化数据"
actions={<LiveDataBadge />}
extra={
<div className="flex flex-wrap items-center gap-2">
@@ -56,15 +56,15 @@ export function AnalyticsPage() {
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
</div>
{/* 老板看板 */}
<h2 className="mt-6 mb-3 text-lg font-semibold">老板看板 <LiveDataBadge className="align-middle" /></h2>
{/* 业务概览 */}
<h2 className="mt-6 mb-3 text-lg font-semibold">业务概览 <LiveDataBadge className="align-middle" /></h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card><CardHeader title="🔥 今日需处理的高价值客户" /><div className="divide-y divide-border">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground">{c.reason.slice(0, 20)}…</span></div>))}</div></Card>
<Card><CardHeader title="⚠️ 存在逾期跟进的业务员" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
<Card><CardHeader title="📈 客户增长最快的国家" /><div className="divide-y divide-border">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-4 py-2.5 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
<Card><CardHeader title="🏅 最优渠道" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
<Card><CardHeader title="📊 最热门产品" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
<Card><CardHeader title="🚨 正在流失的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
<Card><CardHeader title="重点跟进客户" /><div className="divide-y divide-border">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground">{c.reason.slice(0, 20)}…</span></div>))}</div></Card>
<Card><CardHeader title="逾期跟进" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
<Card><CardHeader title="客户增长较快的国家" /><div className="divide-y divide-border">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-4 py-2.5 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
<Card><CardHeader title="渠道转化" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
<Card><CardHeader title="产品询盘" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
<Card><CardHeader title="久未联系的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
</div>
</div>
)
+18 -11
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
@@ -28,6 +28,7 @@ type FormValues = z.infer<typeof schema>
export function LoginPage() {
const { t, locale, setLocale } = useI18n()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const setSession = useAuthStore((s) => s.setSession)
const [mode, setMode] = useState<'code' | 'password'>('password')
const [remember, setRemember] = useState(true)
@@ -42,6 +43,12 @@ export function LoginPage() {
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } })
useEffect(() => {
if (searchParams.get('reason') !== 'session-expired') return
toast.warning('登录状态已过期,请重新登录')
setSearchParams({}, { replace: true })
}, [searchParams, setSearchParams])
const doLogin = async (values: FormValues) => {
if (!agreed) {
toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
@@ -94,16 +101,16 @@ export function LoginPage() {
</div>
<div className="space-y-8">
<h1 className="text-2xl font-semibold leading-snug">
把分散在全球渠道的客户对话,
集中处理客户消息,
<br />
沉淀为可成交的企业资产。
更快回复和跟进。
</h1>
<div className="space-y-4">
{[
{ icon: MessageCircle, text: 'WhatsApp / Telegram / 邮件 / 独立站,统一收件箱' },
{ icon: Languages, text: 'AI 翻译与行业术语,跨语种沟通专业自然' },
{ icon: ShieldCheck, text: '情感分析、风险预警、文化敏感检测' },
{ icon: UsersRound, text: '客户自动建档、意向评分与团队协同跟进' },
{ icon: MessageCircle, text: '集中处理 WhatsApp、Telegram、邮件和独立站消息' },
{ icon: Languages, text: '消息翻译与双语对照,支持行业术语' },
{ icon: ShieldCheck, text: '识别客户情绪、沟通意图和风险信号' },
{ icon: UsersRound, text: '管理客户资料、意向评分和团队跟进' },
].map((f) => (
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-white/10">
@@ -209,9 +216,9 @@ export function LoginPage() {
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
<p>2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。</p>
<p>3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。</p>
<p>4. 使用 AI 功能时,相关内容仅用于为您生成翻译与回复建议,不会用于其他用途。</p>
<p>2. 我们按照已公布的安全措施处理会话数据;涉及第三方服务时,处理范围以隐私政策及相关服务条款为准。</p>
<p>3. 数据导出、删除和账号注销申请按照正式发布的隐私政策处理。</p>
<p>4. 使用翻译和回复建议时,相关内容可能发送至已配置的模型服务商处理,具体范围以隐私政策为准。</p>
</div>
</Dialog>
+1 -1
View File
@@ -51,7 +51,7 @@ export function RegisterPage() {
const session = await registerApi(values)
setSession(session)
toast.success('企业创建成功', '接下来请完成企业初始化配置')
navigate('/channels?setup=telegram')
navigate('/channels?setup=choose')
} catch (e) {
toast.error(errorMessage(e))
} finally {
+1 -1
View File
@@ -33,7 +33,7 @@ export function BillingPage() {
return (
<div className="p-4 md:p-6">
<PageHeader title="套餐与账单" description="通过 Stripe Checkout 管理真实订阅与支付" />
<PageHeader title="套餐与账单" description="管理套餐、订阅和支付记录" />
{info && (
<Card className="mb-4 p-4">
+250 -85
View File
@@ -1,20 +1,23 @@
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Activity,
ArrowUpRight,
AlertTriangle,
Cable,
CheckCircle2,
History,
Mail,
MessageCircle,
Plus,
MessageSquareText,
Pause,
Play,
RefreshCw,
Settings,
ShieldCheck,
Smartphone,
Star,
Trash2,
Workflow,
} from 'lucide-react'
@@ -34,7 +37,8 @@ import { ChannelIcon } from '@/components/common/ChannelIcon'
import {
getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs,
connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword,
getTelegramLoginState, syncTelegramChannel,
getTelegramLoginState, syncChannel, startWhatsAppLogin, getWhatsAppLoginState,
setDefaultChannel,
} from '@/services/api/channels'
import { CHANNEL_META } from '@/config/constants'
import { formatRelative } from '@/utils/format'
@@ -60,13 +64,21 @@ export function ChannelsPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const queryClient = useQueryClient()
const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: getChannels })
const { data: channels } = useQuery({
queryKey: ['channels'],
queryFn: getChannels,
refetchInterval: 15_000,
})
const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
const [telegramAccount, setTelegramAccount] = useState('')
const [telegramCode, setTelegramCode] = useState('')
const [telegramPassword, setTelegramPassword] = useState('')
const [loginChannelId, setLoginChannelId] = useState('')
const [authState, setAuthState] = useState('')
const [whatsappAccount, setWhatsAppAccount] = useState('')
const [whatsappChannelId, setWhatsAppChannelId] = useState('')
const [whatsappPairingCode, setWhatsAppPairingCode] = useState('')
const [whatsappQr, setWhatsAppQr] = useState('')
const [connecting, setConnecting] = useState(false)
const [emailPreset, setEmailPreset] = useState<keyof typeof EMAIL_PRESETS>('custom')
const [emailForm, setEmailForm] = useState({
@@ -85,7 +97,6 @@ export function ChannelsPage() {
const [logDrawer, setLogDrawer] = useState<string | null>(null)
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
const setupOpenedRef = useRef(false)
const loadLogs = async (chId: string) => {
const logs = await getChannelLogs(chId)
@@ -122,7 +133,7 @@ export function ChannelsPage() {
const completeTelegramBinding = async (channelId: string) => {
let syncSummary = ''
try {
const result = await syncTelegramChannel(channelId)
const result = await syncChannel(channelId)
syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息`
} catch (error) {
syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}`
@@ -155,15 +166,48 @@ export function ChannelsPage() {
}
}
useEffect(() => {
if (!channels || setupOpenedRef.current || searchParams.get('setup') !== 'telegram') return
setupOpenedRef.current = true
const telegram = channels.find((item) => item.type === 'telegram')
void openTelegramWizard(telegram)
const openWhatsAppWizard = async (channel?: ChannelConnection) => {
setWhatsAppAccount(channel?.account || '')
setWhatsAppChannelId(channel && channel.id !== 'whatsapp-available' ? channel.id : '')
setWhatsAppPairingCode(channel?.pairingCode || '')
setWhatsAppQr(channel?.qrDataUrl || '')
setWizard({ open: true, type: 'whatsapp', step: channel?.pairingCode ? 1 : 0 })
if (!channel || channel.id === 'whatsapp-available') return
setConnecting(true)
try {
const current = await getWhatsAppLoginState(channel.id)
if (current.status === 'connected') {
await syncChannel(channel.id, { full: true })
toast.success('WhatsApp 已连接并完成首次会话同步')
setWizard({ open: false, type: null, step: 0 })
await queryClient.invalidateQueries()
} else {
const restarted = await startWhatsAppLogin(channel.account || '')
setWhatsAppChannelId(restarted.id)
setWhatsAppPairingCode(restarted.pairingCode || '')
setWhatsAppQr(restarted.qrDataUrl || '')
setWizard({ open: true, type: 'whatsapp', step: 1 })
toast.success('已生成新的配对码,请在手机 WhatsApp 中完成关联设备')
await queryClient.invalidateQueries({ queryKey: ['channels'] })
}
} catch (error) {
toast.error(errorMessage(error))
} finally {
setConnecting(false)
}
}
const openChannelWizard = (type: 'telegram' | 'whatsapp' | 'email', channel?: ChannelConnection) => {
setSearchParams({}, { replace: true })
// 新注册后的 setup 参数只消费一次,避免查询刷新时重复打开向导。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channels, searchParams, setSearchParams])
if (type === 'telegram') {
void openTelegramWizard(channel)
} else if (type === 'whatsapp') {
void openWhatsAppWizard(channel)
} else {
applyEmailPreset('custom')
setWizard({ open: true, type: 'email', step: 0 })
}
}
const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => {
setEmailPreset(value)
@@ -180,8 +224,7 @@ export function ChannelsPage() {
}
const connectedCount = list.filter((channel) => channel.status === 'connected').length
const telegramChannel = list.find((channel) => channel.type === 'telegram')
const telegramConnected = telegramChannel?.status === 'connected'
const setupChoice = searchParams.get('setup') === 'choose'
const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0)
const latestSync = list
.map((channel) => channel.lastSyncAt)
@@ -200,11 +243,11 @@ export function ChannelsPage() {
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-400 opacity-45" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
</span>
Unified communication hub
渠道连接
</div>
<h1 className="text-2xl font-bold tracking-[-0.035em] text-slate-950 md:text-3xl">渠道与账号</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
将 Telegram 即时消息与企业邮箱汇入同一个客户工作台,统一翻译、画像、分配和跟进。
在一个工作台管理 WhatsApp、Telegram 与企业邮箱消息。
</p>
</div>
<div className="grid grid-cols-3 gap-2 sm:min-w-[460px]">
@@ -228,65 +271,87 @@ export function ChannelsPage() {
description="连接状态、同步进度与安全设置"
/>
{!telegramConnected && (
{setupChoice && (
<Card
data-testid="telegram-onboarding"
data-testid="channel-onboarding"
className="mb-5 overflow-hidden border-primary-200/80 bg-gradient-to-r from-primary-50/90 via-white to-sky-50 shadow-[0_16px_45px_rgba(37,99,235,.10)]"
>
<div className="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:p-6">
<div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="primary">首次配置 · 约 2 分钟</Badge>
<span className="text-xs font-medium text-slate-500">使用你自己的 Telegram 工作账号</span>
</div>
<h2 className="mt-3 text-lg font-bold tracking-tight text-slate-950">先绑定 Telegram,开始接收真实客户消息</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-6 text-slate-600">
输入国际格式手机号,依次完成 Telegram 验证码和可选的二步密码。授权成功后系统会自动注册实时 Webhook,并首次同步历史会话。
</p>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{[
{ icon: Smartphone, step: '01', title: '验证手机号', text: '例如 +8613800138000' },
{ icon: ShieldCheck, step: '02', title: '完成安全验证', text: '验证码与二步密码' },
{ icon: RefreshCw, step: '03', title: '自动同步消息', text: '历史会话与实时消息' },
].map((item) => (
<div key={item.step} className="flex items-center gap-3 rounded-xl border border-white bg-white/80 px-3 py-2.5 shadow-sm">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-50 text-primary">
<item.icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-semibold text-slate-800">{item.step} · {item.title}</p>
<p className="mt-0.5 truncate text-[10px] text-slate-400">{item.text}</p>
</div>
</div>
))}
</div>
<div className="p-5 lg:p-6">
<Badge variant="primary">企业创建成功 · 渠道可稍后配置</Badge>
<h2 className="mt-3 text-xl font-bold tracking-tight text-slate-950">选择第一个客户沟通渠道</h2>
<p className="mt-1.5 text-sm leading-6 text-slate-600">
不强制绑定 Telegram。你可以连接 WhatsApp、Telegram 或企业邮箱,也可以先进入系统,之后再添加多个账号。
</p>
<div className="mt-5 grid gap-3 md:grid-cols-3">
{[
{ type: 'telegram' as const, icon: Cable, title: 'Telegram', text: '手机号、验证码与可选二步密码' },
{ type: 'whatsapp' as const, icon: MessageCircle, title: 'WhatsApp', text: '普通账号通过关联设备配对' },
{ type: 'email' as const, icon: Mail, title: '企业邮箱', text: 'IMAP 收信与 SMTP 发信' },
].map((item) => (
<button
type="button"
key={item.type}
data-testid={`choose-${item.type}`}
onClick={() => openChannelWizard(item.type)}
className="group rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-lg"
>
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-50 text-primary transition group-hover:bg-primary group-hover:text-white">
<item.icon className="h-5 w-5" />
</span>
<p className="mt-3 text-sm font-bold text-slate-900">{item.title}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{item.text}</p>
</button>
))}
</div>
<div className="mt-4 flex justify-end">
<Button variant="ghost" onClick={() => setSearchParams({}, { replace: true })}>
暂不配置,先进入系统
</Button>
</div>
<Button
size="lg"
className="w-full shadow-lg shadow-primary/20 lg:w-auto"
loading={connecting}
onClick={() => void openTelegramWizard(telegramChannel)}
>
<Cable className="h-4 w-4" />
{telegramChannel && telegramChannel.id !== 'telegram-available' ? '继续 Telegram 授权' : '开始绑定 Telegram'}
</Button>
</div>
</Card>
)}
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
<div>
<p className="text-sm font-semibold text-slate-900">连接更多工作账号</p>
<p className="mt-0.5 text-xs text-slate-500">每种渠道可绑定多个账号,并指定一个默认账号用于发起新会话。</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => openChannelWizard('telegram')}>
<Plus className="h-3.5 w-3.5" />Telegram
</Button>
<Button size="sm" variant="outline" onClick={() => openChannelWizard('whatsapp')}>
<Plus className="h-3.5 w-3.5" />WhatsApp
</Button>
<Button size="sm" variant="outline" onClick={() => openChannelWizard('email')}>
<Plus className="h-3.5 w-3.5" />企业邮箱
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{list.map((ch) => {
const meta = CHANNEL_META[ch.type]
const st = STATUS_MAP[ch.status]
return (
<Card data-testid={`channel-card-${ch.type}`} key={ch.id} className="group overflow-hidden border-slate-200/80 bg-white transition-all duration-300 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-[0_18px_50px_rgba(15,23,42,.08)]">
<div className={ch.type === 'telegram' ? 'h-1 bg-gradient-to-r from-sky-400 to-cyan-300' : 'h-1 bg-gradient-to-r from-slate-500 to-slate-300'} />
<div className={
ch.type === 'telegram'
? 'h-1 bg-gradient-to-r from-sky-400 to-cyan-300'
: ch.type === 'whatsapp'
? 'h-1 bg-gradient-to-r from-blue-500 via-cyan-400 to-violet-400'
: 'h-1 bg-gradient-to-r from-slate-500 to-slate-300'
} />
<div className="p-5">
<div className="flex items-start justify-between">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-slate-100 transition-transform duration-300 group-hover:scale-105">
<ChannelIcon channel={ch.type} size="lg" />
</div>
<Badge className={st.color}>{st.label}</Badge>
<div className="flex flex-wrap justify-end gap-1.5">
{ch.isDefault && <Badge variant="primary"><Star className="mr-1 h-3 w-3 fill-current" />默认账号</Badge>}
<Badge className={st.color}>{st.label}</Badge>
</div>
</div>
<div className="mt-4">
<p className="text-base font-semibold tracking-tight text-slate-950">{meta.name}</p>
@@ -300,8 +365,14 @@ export function ChannelsPage() {
<p className="mt-0.5 text-sm font-semibold text-slate-800">{Number(ch.todayMessages || 0)} 条</p>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
<p className="text-[10px] font-medium text-slate-400">同步状态</p>
<p className="mt-0.5 truncate text-sm font-semibold text-slate-800">{ch.lastSyncAt ? formatRelative(ch.lastSyncAt) : '等待同步'}</p>
<p className="text-[10px] font-medium text-slate-400">消息连接</p>
<p className="mt-0.5 truncate text-sm font-semibold text-slate-800">
{ch.status === 'connected'
? '实时同步中'
: ch.lastSyncAt
? formatRelative(ch.lastSyncAt)
: '等待连接'}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
@@ -319,31 +390,33 @@ export function ChannelsPage() {
<div className="mt-5 flex flex-wrap items-center gap-1 border-t border-slate-100 pt-4">
{ch.status === 'connected' ? (
<>
<Button size="sm" variant="outline" onClick={async () => {
try {
const result = await syncTelegramChannel(ch.id)
toast.success(`同步完成:${result.chats} 个会话,${result.messages} 条新消息`)
queryClient.invalidateQueries()
} catch (error) {
toast.error(errorMessage(error))
}
}}><RefreshCw className="h-3 w-3" />立即同步</Button>
<Button size="sm" variant="outline" onClick={async () => {
try {
await updateChannel(ch.id, { status: 'paused' })
toast.success('渠道已暂停,实时 Webhook 已停用')
toast.success('渠道已暂停接收实时消息')
queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) {
toast.error(errorMessage(error))
}
}}><Pause className="h-3 w-3" />暂停</Button>
{!ch.isDefault && (
<Button size="sm" variant="outline" onClick={async () => {
try {
await setDefaultChannel(ch.id)
toast.success(`${ch.account || meta.name} 已设为默认账号`)
queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) {
toast.error(errorMessage(error))
}
}}><Star className="h-3 w-3" />设为默认</Button>
)}
<Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
</>
) : ch.status === 'paused' ? (
<Button size="sm" variant="outline" onClick={async () => {
try {
await updateChannel(ch.id, { status: 'connected' })
toast.success('渠道已恢复,实时 Webhook 已启用')
toast.success('渠道已恢复接收实时消息')
queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) {
toast.error(errorMessage(error))
@@ -357,6 +430,8 @@ export function ChannelsPage() {
if (ch.type === 'email') {
applyEmailPreset('custom')
setWizard({ open: true, type: ch.type, step: 0 })
} else if (ch.type === 'whatsapp') {
void openWhatsAppWizard(ch)
} else {
void openTelegramWizard(ch)
}
@@ -370,17 +445,27 @@ export function ChannelsPage() {
if (ch.type === 'email') {
applyEmailPreset('custom')
setWizard({ open: true, type: ch.type, step: 0 })
} else if (ch.type === 'whatsapp') {
void openWhatsAppWizard(ch)
} else {
void openTelegramWizard(ch)
}
}}
>
{ch.type === 'email' ? <Mail className="h-3 w-3" /> : <Cable className="h-3 w-3" />}
{ch.type === 'telegram' && ch.id !== 'telegram-available' ? '继续授权' : '连接'}
{ch.type === 'email'
? <Mail className="h-3 w-3" />
: ch.type === 'whatsapp'
? <MessageCircle className="h-3 w-3" />
: <Cable className="h-3 w-3" />}
{['telegram', 'whatsapp'].includes(ch.type) && !ch.id.endsWith('-available') ? '继续授权' : '连接'}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => loadLogs(ch.id)}><History className="h-3 w-3" /></Button>
<Button size="sm" variant="ghost" onClick={() => setSettingsDrawer(ch)}><Settings className="h-3 w-3" /></Button>
{!ch.id.endsWith('-available') && (
<>
<Button size="sm" variant="ghost" onClick={() => loadLogs(ch.id)}><History className="h-3 w-3" /></Button>
<Button size="sm" variant="ghost" onClick={() => setSettingsDrawer(ch)}><Settings className="h-3 w-3" /></Button>
</>
)}
</div>
</div>
</Card>
@@ -392,13 +477,13 @@ export function ChannelsPage() {
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-white shadow-lg shadow-primary/20">
<Workflow className="h-5 w-5" />
</span>
<h3 className="mt-4 text-base font-semibold text-slate-900">统一处理链路</h3>
<p className="mt-1 text-xs leading-5 text-slate-500">无论消息来自 Telegram 还是 Email,都进入同一套真实业务流程。</p>
<h3 className="mt-4 text-base font-semibold text-slate-900">消息处理</h3>
<p className="mt-1 text-xs leading-5 text-slate-500">渠道消息进入收件箱后,可分配负责人、翻译并跟进。</p>
<div className="mt-4 space-y-2">
{[
['01', '实时接收与自动同步'],
['02', 'AI 翻译、情绪与意图识别'],
['03', '客户画像、风险与任务联动'],
['01', '接收并同步渠道消息'],
['02', '分配负责人并处理消息'],
['03', '记录客户信息与跟进事项'],
].map(([step, label]) => (
<div key={step} className="flex items-center gap-3 rounded-xl bg-white px-3 py-2.5 shadow-sm ring-1 ring-slate-100">
<span className="text-[10px] font-bold text-primary">{step}</span>
@@ -421,6 +506,9 @@ export function ChannelsPage() {
if (!open) {
setEmailForm((current) => ({ ...current, password: '' }))
resetTelegramWizard()
setWhatsAppChannelId('')
setWhatsAppPairingCode('')
setWhatsAppQr('')
}
}}
title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
@@ -438,6 +526,35 @@ export function ChannelsPage() {
queryClient.invalidateQueries()
return
}
if (wizard.type === 'whatsapp') {
if (!whatsappChannelId) {
const phone = whatsappAccount.replace(/\D/g, '')
if (!/^\d{7,15}$/.test(phone)) {
toast.warning('请输入包含国家/地区代码的 WhatsApp 手机号')
return
}
const channel = await startWhatsAppLogin(phone)
setWhatsAppChannelId(channel.id)
setWhatsAppPairingCode(channel.pairingCode || '')
setWhatsAppQr(channel.qrDataUrl || '')
setWizard({ open: true, type: 'whatsapp', step: 1 })
toast.success('配对码已生成,请在手机 WhatsApp 中完成关联设备')
queryClient.invalidateQueries({ queryKey: ['channels'] })
return
}
const state = await getWhatsAppLoginState(whatsappChannelId)
setWhatsAppPairingCode(state.pairingCode || state.channel.pairingCode || '')
setWhatsAppQr(state.qrDataUrl || state.channel.qrDataUrl || '')
if (state.status !== 'connected') {
toast.info('尚未检测到手机确认,请完成关联设备后再次检查')
return
}
const sync = await syncChannel(whatsappChannelId, { full: true })
toast.success(`WhatsApp 绑定成功,已同步 ${sync.chats} 个会话和 ${sync.messages} 条消息`)
setWizard({ open: false, type: null, step: 0 })
await queryClient.invalidateQueries()
return
}
if (!loginChannelId) {
const phone = telegramAccount.replace(/\s+/g, '')
@@ -497,7 +614,11 @@ export function ChannelsPage() {
>
{wizard.type === 'email'
? '验证并连接邮箱'
: !loginChannelId
: wizard.type === 'whatsapp'
? !whatsappChannelId
? '生成 WhatsApp 配对码'
: '我已在手机确认,检查连接'
: !loginChannelId
? '发送 Telegram 验证码'
: authState === 'waiting_password'
? '验证二步密码并完成绑定'
@@ -513,7 +634,7 @@ export function ChannelsPage() {
IMAP 收信 + SMTP 发信
</div>
<p className="mt-1 text-xs leading-5 text-sky-700">
密码在服务端使用 AES-256-GCM 加密保存,不会返回浏览器。Gmail、Outlook 等通常需要应用专用密码。
Gmail、Outlook 等邮箱通常需要使用应用专用密码。
</p>
</div>
<Field label="邮箱服务商">
@@ -545,7 +666,7 @@ export function ChannelsPage() {
type="password"
value={emailForm.password}
onChange={(event) => setEmailForm({ ...emailForm, password: event.target.value })}
placeholder="仅提交到本地服务端验证"
placeholder="邮箱密码或应用专用密码"
/>
</Field>
<div className="grid grid-cols-[1fr_96px] gap-3">
@@ -585,6 +706,50 @@ export function ChannelsPage() {
</label>
</div>
</div>
) : wizard.type === 'whatsapp' ? (
<div className="space-y-4">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900">
<AlertTriangle className="h-4 w-4" />
普通 WhatsApp 关联设备接入
</div>
<p className="mt-1 text-xs leading-5 text-amber-800">
该方式通过 WhatsApp 的“关联设备”连接普通个人或 Business App 账号,并非 Meta 官方 Business Cloud API。
请勿用于垃圾营销或违反 WhatsApp 条款的自动化行为。
</p>
</div>
{!whatsappChannelId ? (
<Field label="WhatsApp 手机号" required hint="包含国家/地区代码,例如中国号码输入 +8613800138000">
<Input
type="tel"
autoComplete="tel"
value={whatsappAccount}
onChange={(event) => setWhatsAppAccount(event.target.value)}
placeholder="+8613800138000"
/>
</Field>
) : (
<>
<div className="rounded-2xl border border-primary-200 bg-primary-50/70 p-5 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary-600">关联设备配对码</p>
<p className="mt-3 font-mono text-3xl font-black tracking-[0.2em] text-primary-950">
{whatsappPairingCode || '等待生成'}
</p>
<p className="mt-3 text-xs leading-5 text-primary-700">
手机 WhatsApp → 设置 → 关联设备 → 关联设备 → 使用手机号关联,然后输入上方配对码。
</p>
</div>
{whatsappQr && (
<div className="flex justify-center rounded-2xl border border-slate-200 bg-white p-4">
<img src={whatsappQr} alt="WhatsApp 关联设备二维码" className="h-48 w-48 rounded-xl" />
</div>
)}
</>
)}
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-xs leading-5 text-slate-600">
手机确认后将自动同步历史会话、联系人和媒体消息。
</div>
</div>
) : (
<div className="space-y-4">
<div className="rounded-xl border border-primary-100 bg-primary-50/70 p-3.5">
@@ -593,7 +758,7 @@ export function ChannelsPage() {
<span className="text-sm font-semibold text-primary-900">绑定你自己的 Telegram 工作账号</span>
</div>
<p className="mt-1 text-xs leading-5 text-primary-700">
每个企业独立绑定账号,不会共享其他租户的 Telegram 会话或凭据。
可以继续添加其他工作账号,并选择一个默认账号发起新会话。
</p>
</div>
<div aria-label="Telegram 绑定进度" className="grid grid-cols-3 gap-2">
@@ -655,7 +820,7 @@ export function ChannelsPage() {
</Field>
)}
<p className="text-xs leading-5 text-muted-foreground">
手机号、验证码和二步密码只通过加密连接发送到本企业的 Telegram Gateway,不写入浏览器本地存储。授权成功后将自动完成 Webhook 注册和首次历史同步。
完成验证后将自动同步历史会话、联系人和媒体消息。
</p>
</div>
)}
+5 -5
View File
@@ -68,7 +68,7 @@ export function CustomerDetailPage() {
customerName: c.name,
ownerId: user?.id || '',
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
note: `由客户画像的 AI 建议创建:${title}`,
note: `由客户画像建议创建:${title}`,
})
await queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整')
@@ -115,7 +115,7 @@ export function CustomerDetailPage() {
</div>
) : (
<p className="text-xs leading-5 text-slate-400">
暂无标签,AI 将在收到有效客户消息后自动补全。
暂无标签,收到更多客户消息后将自动补充。
</p>
)}
</div>
@@ -159,7 +159,7 @@ export function CustomerDetailPage() {
<CustomerProfileRadar customer={c} className="xl:col-span-5" />
<Card className="xl:col-span-7">
<CardHeader title="AI 客户洞察" description="结合完整会话、购买信号和风险变化生成" />
<CardHeader title="客户洞察" description="参考已同步的会话、购买信号和风险变化" />
<div className="p-5">
<div className="grid grid-cols-3 gap-2">
{[
@@ -174,7 +174,7 @@ export function CustomerDetailPage() {
))}
</div>
<p className="mt-5 text-sm leading-7 text-slate-600">
{c.aiProfile.summary || 'AI 正在根据 Telegram 会话补全客户画像。'}
{c.aiProfile.summary || '收到更多客户消息后将补充客户画像。'}
</p>
{c.aiProfile.evidence?.length ? (
<div className="mt-5 border-t border-slate-100 pt-4">
@@ -193,7 +193,7 @@ export function CustomerDetailPage() {
</Card>
<Card className="xl:col-span-8">
<CardHeader title="采购情报" description="从 Telegram 对话中自动结构化提取" />
<CardHeader title="采购信息" description="根据已同步的会话内容整理" />
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-b-2xl bg-slate-100 md:grid-cols-4">
{Object.entries({
询价数量: c.aiProfile.inquiryQuantity,
+1 -1
View File
@@ -178,7 +178,7 @@ export function CustomersPage() {
<div className="p-4 md:p-6">
<PageHeader
title="客户管理"
description={`共 ${total} 个客户 · 统一管理与跟进`}
description={`共 ${total} 个客户`}
actions={
<>
<PermissionGate permission="customer:export">
+3 -3
View File
@@ -127,8 +127,8 @@ export function DashboardPage() {
{/* 核心指标 */}
<div className="mb-3 flex items-end justify-between">
<div>
<p className="eyebrow">Today</p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">今日经营重点</h2>
<p className="eyebrow">工作台</p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">今日重点</h2>
</div>
<button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button>
</div>
@@ -252,7 +252,7 @@ export function DashboardPage() {
<div className="border-t border-border p-3">
<Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}>
<Send className="h-4 w-4" />
进入统一消息中心
查看全部消息
</Button>
</div>
</Card>
+2 -2
View File
@@ -7,8 +7,8 @@ const HELP_ITEMS = [
{ icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' },
{ icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' },
{ icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' },
{ icon: Languages, title: 'AI 上下文翻译', desc: '结合当前会话上下文进行跨境商务翻译', path: '/translate' },
{ icon: UsersRound, title: '邀请团队成员', desc: '通过已配置的企业 SMTP 发送真实邀请邮件', path: '/team' },
{ icon: Languages, title: '消息翻译', desc: '翻译当前消息并保留双语对照', path: '/translate' },
{ icon: UsersRound, title: '邀请团队成员', desc: '向同事发送团队邀请邮件', path: '/team' },
{ icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' },
]
+438 -70
View File
@@ -22,15 +22,21 @@ import {
getConversations,
getConversation,
getMessages,
normalizeMessage,
sendMessage,
sendMedia,
sendAlbum,
updateConversation,
updateMessage,
markConversationRead,
setConversationPresence,
openTelegramConversation,
runTelegramConversationAction,
type TelegramConversationAction,
openWhatsAppConversation,
runConversationAction,
runWhatsAppMessageAction,
getWhatsAppGroup,
runWhatsAppGroupAction,
type ConversationAction,
type ConversationListItem,
} from '@/services/api/conversations'
import { updateCustomer } from '@/services/api/customers'
@@ -127,6 +133,8 @@ export function InboxPage() {
})
const [draftText, setDraftText] = useState('')
const [draftTranslationCn, setDraftTranslationCn] = useState('')
const presenceStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastPresenceSentAt = useRef(0)
const [replyingTo, setReplyingTo] = useState<{ id: string; text: string } | null>(null)
const [noteTarget, setNoteTarget] = useState<Message | null>(null)
const [noteDraft, setNoteDraft] = useState('')
@@ -136,14 +144,17 @@ export function InboxPage() {
customer: Customer
} | null>(null)
const [addTelegramOpen, setAddTelegramOpen] = useState(false)
const [addConversationChannel, setAddConversationChannel] = useState<'telegram' | 'whatsapp'>('telegram')
const [addTelegramType, setAddTelegramType] = useState<'user' | 'chat' | 'invite'>('user')
const [addTelegramIdentifier, setAddTelegramIdentifier] = useState('')
const [addGroupParticipants, setAddGroupParticipants] = useState('')
const [addTelegramLoading, setAddTelegramLoading] = useState(false)
const [conversationActionTarget, setConversationActionTarget] = useState<{
item: ConversationListItem
action: 'clear' | 'delete' | 'leave'
} | null>(null)
const [conversationActionLoading, setConversationActionLoading] = useState(false)
const [whatsappGroupOpen, setWhatsAppGroupOpen] = useState(false)
const messageListRef = useRef<HTMLDivElement>(null)
const messageContentRef = useRef<HTMLDivElement>(null)
@@ -305,14 +316,15 @@ export function InboxPage() {
})
}
if (event.conversationId && event.message) {
const normalizedMessage = normalizeMessage(event.message)
queryClient.setQueryData<Message[]>(['messages', event.conversationId], (current = []) => {
const withoutOld = event.oldMessageId
? current.filter((item) => String(item.id) !== String(event.oldMessageId))
: current
const index = withoutOld.findIndex((item) => String(item.id) === String(event.message!.id))
if (index < 0) return [...withoutOld, event.message!]
const index = withoutOld.findIndex((item) => String(item.id) === String(normalizedMessage.id))
if (index < 0) return [...withoutOld, normalizedMessage]
const next = [...withoutOld]
next[index] = { ...next[index], ...event.message! }
next[index] = normalizeMessage({ ...next[index], ...normalizedMessage })
return next
})
queryClient.setQueriesData<{ list: Array<{ id: string; lastMessage?: Message }>; total: number }>(
@@ -325,16 +337,23 @@ export function InboxPage() {
String(item?.lastMessage?.id || '') === String(event.message?.id || '')
if (!isNewMessage && !isCurrentLastMessage) return typed
return updateRealtimeConversationList(typed, event.conversationId!, {
lastMessage: event.message,
lastMessage: normalizedMessage,
})
},
)
}
if (event.conversationId && event.conversation) {
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => ({
conversation: event.conversation,
customer: event.customer || current?.customer,
}))
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => {
const customer = event.customer || current?.customer
// A realtime conversation event can arrive before the detail request.
// Do not seed an incomplete detail cache because the chat header and
// insight components require both the conversation and its customer.
if (!customer) return current
return {
conversation: event.conversation,
customer,
}
})
queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>(
{ queryKey: ['conversations'] },
(current) => updateRealtimeConversationList(current, event.conversationId!, {
@@ -436,9 +455,30 @@ export function InboxPage() {
autoTranslate,
translationCn: draftTranslationCn || undefined,
})
if (convDetail?.conversation.channel === 'whatsapp') {
void setConversationPresence(convId, 'paused').catch(() => undefined)
}
setDraftTranslationCn('')
}
const handleDraftChange = useCallback((text: string) => {
setDraftText(text)
if (!convId || convDetail?.conversation.channel !== 'whatsapp') return
if (presenceStopTimer.current) clearTimeout(presenceStopTimer.current)
const now = Date.now()
if (text.trim() && now - lastPresenceSentAt.current > 2_000) {
lastPresenceSentAt.current = now
void setConversationPresence(convId, 'composing').catch(() => undefined)
}
presenceStopTimer.current = setTimeout(() => {
void setConversationPresence(convId, 'paused').catch(() => undefined)
}, 1_400)
}, [convId, convDetail?.conversation.channel])
useEffect(() => () => {
if (presenceStopTimer.current) clearTimeout(presenceStopTimer.current)
}, [])
const handleMedia = async (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => {
if (!convId) return
const encoded = await Promise.all(files.map(async (file) => {
@@ -467,14 +507,15 @@ export function InboxPage() {
}
}
const executeTelegramAction = async (
action: TelegramConversationAction,
const executeConversationAction = async (
action: ConversationAction,
item?: ConversationListItem,
) => {
const id = item?.id || convId
if (!id) return
const provider = (item?.channel || convDetail?.conversation.channel) === 'whatsapp' ? 'WhatsApp' : 'Telegram'
try {
const result = await runTelegramConversationAction(id, action)
const result = await runConversationAction(id, action)
if ('deleted' in result && result.deleted) {
queryClient.removeQueries({ queryKey: ['messages', id] })
queryClient.removeQueries({ queryKey: ['conversation', id] })
@@ -482,9 +523,9 @@ export function InboxPage() {
} else {
await refreshConversationState(id)
}
const labels: Partial<Record<TelegramConversationAction, string>> = {
const labels: Partial<Record<ConversationAction, string>> = {
clear: '消息记录已清空',
delete: '会话已从 Telegram 列表删除',
delete: `会话已从 ${provider} 列表删除`,
pin: '会话已置顶',
unpin: '已取消置顶',
archive: '会话已归档',
@@ -493,10 +534,10 @@ export function InboxPage() {
unmute: '会话已恢复通知',
'mark-unread': '会话已标记为未读',
leave: '已退出群组或频道',
block: '已拉黑该 Telegram 用户',
block: `已拉黑该 ${provider} 用户`,
unblock: '已解除拉黑',
}
toast.success(labels[action] || 'Telegram 操作已完成')
toast.success(labels[action] || `${provider} 操作已完成`)
} catch (error) {
toast.error(errorMessage(error))
throw error
@@ -507,7 +548,7 @@ export function InboxPage() {
if (!conversationActionTarget) return
setConversationActionLoading(true)
try {
await executeTelegramAction(conversationActionTarget.action, conversationActionTarget.item)
await executeConversationAction(conversationActionTarget.action, conversationActionTarget.item)
setConversationActionTarget(null)
} finally {
setConversationActionLoading(false)
@@ -517,20 +558,30 @@ export function InboxPage() {
const addTelegramConversation = async () => {
const identifier = addTelegramIdentifier.trim()
if (!identifier) {
toast.error('请输入 Telegram 标识')
toast.error(`请输入 ${addConversationChannel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 标识`)
return
}
setAddTelegramLoading(true)
try {
const value = await openTelegramConversation({
identifier,
targetType: addTelegramType,
})
const value = addConversationChannel === 'whatsapp'
? await openWhatsAppConversation({
identifier,
targetType: addTelegramType === 'invite' ? 'invite' : addTelegramType === 'chat' ? 'group' : 'user',
subject: addTelegramType === 'chat' ? identifier : undefined,
participants: addTelegramType === 'chat'
? addGroupParticipants.split(/[\s,;]+/).map((item) => item.trim()).filter(Boolean)
: undefined,
})
: await openTelegramConversation({
identifier,
targetType: addTelegramType,
})
await refreshConversationState(value.conversation.id)
setAddTelegramOpen(false)
setAddTelegramIdentifier('')
setAddGroupParticipants('')
setConv(value.conversation.id)
toast.success('Telegram 会话已添加', value.customer.name)
toast.success(`${addConversationChannel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 会话已添加`, value.customer.name)
} catch (error) {
toast.error(errorMessage(error))
} finally {
@@ -542,7 +593,39 @@ export function InboxPage() {
quote: (msg: Message) => {
if (!convId) return
setReplyingTo({ id: msg.id, text: msg.originalText || msg.attachmentName || `[${msg.type}]` })
toast.info('已引用该消息,发送后会关联 Telegram 原消息')
toast.info('已引用该消息,发送后会关联渠道原消息')
},
react: async (msg: Message, emoji: string) => {
if (!convId || convDetail?.conversation.channel !== 'whatsapp') return
try {
const updated = await runWhatsAppMessageAction(convId, {
action: 'react',
messageId: msg.id,
emoji,
})
if ('id' in updated) {
queryClient.setQueryData(['messages', convId], (old: Message[] | undefined) =>
old?.map((item) => item.id === updated.id ? updated : item))
}
} catch (error) {
toast.error(errorMessage(error))
}
},
deleteMessage: async (msg: Message) => {
if (!convId || convDetail?.conversation.channel !== 'whatsapp') return
try {
const updated = await runWhatsAppMessageAction(convId, {
action: 'delete',
messageId: msg.id,
})
if ('id' in updated) {
queryClient.setQueryData(['messages', convId], (old: Message[] | undefined) =>
old?.map((item) => item.id === updated.id ? updated : item))
}
toast.success('消息已撤回')
} catch (error) {
toast.error(errorMessage(error))
}
},
markImportant: async (msg: Message) => {
if (!convId) return
@@ -564,8 +647,9 @@ export function InboxPage() {
try {
const { createTask } = await import('@/services/api/crm')
const dueAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const sourceText = String(msg.originalText || '')
await createTask({
title: `跟进:${msg.originalText.slice(0, 36) || msg.attachmentName || '客户消息'}`,
title: `跟进:${sourceText.slice(0, 36) || msg.attachmentName || '客户消息'}`,
type: 'revisit',
priority: convDetail.customer.riskLevel === 'urgent' || convDetail.customer.riskLevel === 'high' ? 'high' : 'medium',
status: 'pending',
@@ -574,20 +658,21 @@ export function InboxPage() {
conversationId: convId,
ownerId: user?.id || '',
dueAt,
note: `来源消息:${msg.originalText || msg.attachmentName || msg.type}`,
note: `来源消息:${sourceText || msg.attachmentName || msg.type}`,
} as any)
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('已创建真实跟进待办', '默认明天到期,可在商机与跟进中调整')
toast.success('已创建跟进待办', '默认明天到期,可在商机与跟进中调整')
} catch (error) {
toast.error(errorMessage(error))
}
},
knowledge: (msg: Message) => {
const sourceText = String(msg.originalText || '')
import('@/services/api/knowledge')
.then(({ createKnowledge }) =>
createKnowledge({
title: `会话消息提取:${msg.originalText.slice(0, 30)}`,
content: msg.originalText,
title: `会话消息提取:${sourceText.slice(0, 30) || msg.attachmentName || '客户消息'}`,
content: sourceText || msg.attachmentName || `[${msg.type}]`,
source: 'reply',
}),
)
@@ -615,7 +700,7 @@ export function InboxPage() {
open={noteTarget !== null}
onOpenChange={(open) => { if (!open) { setNoteTarget(null); setNoteDraft('') } }}
title="内部备注"
description="仅团队内部可见,不会发送给 Telegram 联系人。"
description="仅团队内部可见,不会发送给外部联系人。"
footer={
<>
<Button variant="ghost" onClick={() => setNoteTarget(null)}>取消</Button>
@@ -636,20 +721,33 @@ export function InboxPage() {
</Dialog>
)
const whatsappGroupDialog = convId ? (
<WhatsAppGroupDialog
conversationId={convId}
open={whatsappGroupOpen}
onOpenChange={setWhatsAppGroupOpen}
/>
) : null
const telegramDialogs = (
<>
<Dialog
open={addTelegramOpen}
onOpenChange={setAddTelegramOpen}
title="添加 Telegram 会话"
description="通过真实 Telegram 标识创建或加入会话,添加后立即同步最近消息。"
title="添加联系人或群组"
description="通过渠道账号创建或加入会话,添加后立即同步最近消息。"
footer={(
<>
<Button variant="ghost" onClick={() => setAddTelegramOpen(false)}>取消</Button>
<Button
onClick={addTelegramConversation}
loading={addTelegramLoading}
disabled={!addTelegramIdentifier.trim()}
disabled={
!addTelegramIdentifier.trim()
|| (addConversationChannel === 'whatsapp'
&& addTelegramType === 'chat'
&& !addGroupParticipants.replace(/\D/g, ''))
}
>
添加并打开
</Button>
@@ -657,6 +755,30 @@ export function InboxPage() {
)}
>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2 rounded-xl bg-slate-100 p-1">
{[
{ value: 'telegram' as const, label: 'Telegram' },
{ value: 'whatsapp' as const, label: 'WhatsApp' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setAddConversationChannel(option.value)
setAddTelegramIdentifier('')
setAddGroupParticipants('')
}}
className={cn(
'rounded-lg px-3 py-2 text-xs font-semibold transition',
addConversationChannel === option.value
? 'bg-white text-primary shadow-sm ring-1 ring-slate-200'
: 'text-slate-500 hover:text-slate-800',
)}
>
{option.label}
</button>
))}
</div>
<div className="grid grid-cols-3 gap-2 rounded-xl bg-slate-100 p-1">
{[
{ value: 'user' as const, label: '用户' },
@@ -679,15 +801,19 @@ export function InboxPage() {
))}
</div>
<div>
<label htmlFor="telegram-identifier" className="mb-1.5 block text-xs font-semibold text-slate-700">
<label htmlFor="channel-identifier" className="mb-1.5 block text-xs font-semibold text-slate-700">
{addTelegramType === 'user'
? '用户名、手机号或用户 ID'
? addConversationChannel === 'whatsapp'
? 'WhatsApp 手机号'
: '用户名、手机号或用户 ID'
: addTelegramType === 'chat'
? '群组/频道用户名或 Chat ID'
: 'Telegram 邀请链接'}
? addConversationChannel === 'whatsapp'
? '新群组名称'
: '群组/频道用户名或 Chat ID'
: `${addConversationChannel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 邀请链接`}
</label>
<input
id="telegram-identifier"
id="channel-identifier"
autoFocus
value={addTelegramIdentifier}
onChange={(event) => setAddTelegramIdentifier(event.target.value)}
@@ -696,15 +822,33 @@ export function InboxPage() {
}}
placeholder={
addTelegramType === 'user'
? '@username、+1 234… 或 123456789'
? addConversationChannel === 'whatsapp'
? '+1 929 738 5556'
: '@username、+1 234… 或 123456789'
: addTelegramType === 'chat'
? '@groupname 或 -1001234567890'
: 'https://t.me/+xxxxxxxx'
? addConversationChannel === 'whatsapp'
? '例如:北美客户服务群'
: '@groupname 或 -1001234567890'
: addConversationChannel === 'whatsapp'
? 'https://chat.whatsapp.com/xxxxxxxx'
: 'https://t.me/+xxxxxxxx'
}
className="h-11 w-full rounded-xl border border-slate-200 bg-white px-3.5 text-sm outline-none transition focus:border-primary-300 focus:ring-4 focus:ring-primary-50"
/>
{addConversationChannel === 'whatsapp' && addTelegramType === 'chat' && (
<input
value={addGroupParticipants}
onChange={(event) => setAddGroupParticipants(event.target.value)}
placeholder="成员手机号,用逗号分隔(含国家代码)"
className="mt-2 h-11 w-full rounded-xl border border-slate-200 bg-white px-3.5 text-sm outline-none transition focus:border-primary-300 focus:ring-4 focus:ring-primary-50"
/>
)}
<p className="mt-2 text-xs leading-5 text-slate-500">
用户 ID 与 Chat ID 含义不同,请先选择正确类型。邀请链接加入群组可能需要管理员审批。
{addConversationChannel === 'whatsapp'
? addTelegramType === 'chat'
? '创建群组至少需要一名成员,手机号必须包含国家代码。'
: '手机号必须包含国家代码;邀请链接会让当前 WhatsApp 账号加入群组。'
: '用户 ID 与 Chat ID 含义不同,请先选择正确类型。邀请链接加入群组可能需要管理员审批。'}
</p>
</div>
</div>
@@ -715,7 +859,7 @@ export function InboxPage() {
onOpenChange={(open) => { if (!open && !conversationActionLoading) setConversationActionTarget(null) }}
title={
conversationActionTarget?.action === 'delete'
? '删除 Telegram 会话'
? `删除 ${conversationActionTarget?.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 会话`
: conversationActionTarget?.action === 'leave'
? '退出群组或频道'
: '清空消息记录'
@@ -743,12 +887,13 @@ export function InboxPage() {
>
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
{conversationActionTarget?.action === 'delete'
? String(conversationActionTarget.item.telegramChatType || '').toLowerCase() === 'channel'
? conversationActionTarget.item.channel === 'telegram'
&& String(conversationActionTarget.item.telegramChatType || '').toLowerCase() === 'channel'
? '这会先退出 Telegram 频道,再从当前账号和灵犀跨境中移除会话;不会全局删除频道帖子,也不会删除客户档案。'
: '这会清除当前 Telegram 账号中的聊天历史并从会话列表移除,但不会删除客户档案,也不会撤回客户已收到的消息。'
: `这会清除当前 ${conversationActionTarget.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 账号中的聊天历史并从会话列表移除,但不会删除客户档案,也不会撤回客户已收到的消息。`
: conversationActionTarget?.action === 'leave'
? '退出后将不能继续收发该群组或频道消息,重新加入可能需要新的邀请链接或管理员审批。'
: '这会清除当前 Telegram 账号和灵犀跨境中的聊天记录,但不会撤回客户已收到的消息。'}
: `这会清除当前 ${conversationActionTarget?.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 账号和灵犀跨境中的聊天记录,但不会撤回客户已收到的消息。`}
</div>
</Dialog>
</>
@@ -808,8 +953,11 @@ export function InboxPage() {
onConvertToTask={() => handleAction.task(msg)}
onAddToKnowledge={() => handleAction.knowledge(msg)}
onAddNote={() => handleAction.addNote(msg)}
customerName={convDetail?.customer.name}
customerAvatar={convDetail?.customer.avatarUrl}
onReact={(emoji) => void handleAction.react(msg, emoji)}
onDelete={() => void handleAction.deleteMessage(msg)}
customerName={convDetail?.customer?.name}
customerAvatar={convDetail?.customer?.avatarUrl}
channel={convDetail?.conversation.channel}
/>
</div>
)
@@ -821,6 +969,7 @@ export function InboxPage() {
return (
<div className="flex h-[calc(100vh-64px)] min-h-0 flex-col overflow-hidden bg-white">
{noteDialog}
{whatsappGroupDialog}
{telegramDialogs}
{incomingNotice && (
<IncomingMessageNotice
@@ -865,14 +1014,15 @@ export function InboxPage() {
if (['clear', 'delete', 'leave'].includes(action)) {
setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' })
} else {
void executeTelegramAction(action, item)
void executeConversationAction(action, item)
}
}}
onGroupManage={() => setWhatsAppGroupOpen(true)}
onBack={() => setMobilePanel('list')}
onInsight={() => setMobilePanel('insight')}
showInsightBtn
/>
{convDetail && (
{convDetail?.customer && convDetail?.conversation && (
<ConversationInsightBar
customer={convDetail.customer}
conversation={convDetail.conversation}
@@ -901,7 +1051,7 @@ export function InboxPage() {
) : (
<div className="flex justify-end border-t border-border px-3 py-1.5">
<Button variant="ghost" size="sm" onClick={() => setShowAiReplies(true)}>
AI 智能回复
回复建议
</Button>
</div>
)}
@@ -910,7 +1060,7 @@ export function InboxPage() {
onMedia={handleMedia}
value={draftText}
onChange={(text) => {
setDraftText(text)
handleDraftChange(text)
setDraftTranslationCn('')
}}
disabled={!convId}
@@ -920,7 +1070,7 @@ export function InboxPage() {
/>
</div>
)}
{mobilePanel === 'insight' && convDetail && (
{mobilePanel === 'insight' && convDetail?.customer && convDetail?.conversation && (
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
<Button variant="ghost" size="icon-sm" title="返回会话" aria-label="返回会话" onClick={() => setMobilePanel('chat')}>
@@ -939,6 +1089,7 @@ export function InboxPage() {
return (
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-white">
{noteDialog}
{whatsappGroupDialog}
{telegramDialogs}
{incomingNotice && (
<IncomingMessageNotice
@@ -993,13 +1144,14 @@ export function InboxPage() {
if (['clear', 'delete', 'leave'].includes(action)) {
setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' })
} else {
void executeTelegramAction(action, item)
void executeConversationAction(action, item)
}
}}
onGroupManage={() => setWhatsAppGroupOpen(true)}
onInsight={() => setShowInsight((v) => !v)}
showInsightBtn
/>
{convDetail && (
{convDetail?.customer && convDetail?.conversation && (
<ConversationInsightBar
customer={convDetail.customer}
conversation={convDetail.conversation}
@@ -1032,7 +1184,7 @@ export function InboxPage() {
) : (
<div className="flex justify-end border-t border-border px-3 py-1.5">
<Button variant="ghost" size="sm" onClick={() => setShowAiReplies(true)}>
AI 智能回复
回复建议
</Button>
</div>
)}
@@ -1041,7 +1193,7 @@ export function InboxPage() {
onMedia={handleMedia}
value={draftText}
onChange={(text) => {
setDraftText(text)
handleDraftChange(text)
setDraftTranslationCn('')
}}
disabled={!convId}
@@ -1053,14 +1205,14 @@ export function InboxPage() {
) : (
<EmptyState
title="选择会话开始沟通"
description="左侧选择一条会话,查看消息记录、AI 分析与客户洞察"
description="从左侧选择会话,查看消息记录、客户洞察和回复建议"
className="flex-1"
/>
)}
</div>
{/* 右栏:客户洞察 340px */}
{showInsight && convDetail && (
{showInsight && convDetail?.customer && convDetail?.conversation && (
<div className="w-[380px] shrink-0 border-s border-slate-200/80 bg-slate-50/40">
<InsightPanel customer={convDetail.customer} conversation={convDetail.conversation} />
</div>
@@ -1069,6 +1221,173 @@ export function InboxPage() {
)
}
function WhatsAppGroupDialog({
conversationId,
open,
onOpenChange,
}: {
conversationId: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
const queryClient = useQueryClient()
const [subject, setSubject] = useState('')
const [description, setDescription] = useState('')
const [member, setMember] = useState('')
const [inviteCode, setInviteCode] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['whatsapp-group', conversationId],
queryFn: () => getWhatsAppGroup(conversationId),
enabled: open,
})
useEffect(() => {
if (!data) return
setSubject(data.name || '')
setDescription(data.description || '')
}, [data])
const action = useMutation({
mutationFn: (payload: Parameters<typeof runWhatsAppGroupAction>[1]) =>
runWhatsAppGroupAction(conversationId, payload),
onSuccess: async (_value, variables) => {
await queryClient.invalidateQueries({ queryKey: ['whatsapp-group', conversationId] })
if (variables.action !== 'invite-code') toast.success('群组设置已更新')
},
onError: (error) => toast.error(errorMessage(error)),
})
const updateInvite = async () => {
try {
const value = await runWhatsAppGroupAction(conversationId, { action: 'invite-code' }) as any
const code = String(value?.code || value || '')
setInviteCode(code ? `https://chat.whatsapp.com/${code}` : '')
} catch (error) {
toast.error(errorMessage(error))
}
}
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title="WhatsApp 群组管理"
description="管理群资料、成员权限和邀请链接。"
footer={<Button variant="ghost" onClick={() => onOpenChange(false)}>关闭</Button>}
>
{isLoading ? <PageLoading /> : (
<div className="space-y-5">
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<input
value={subject}
onChange={(event) => setSubject(event.target.value)}
placeholder="群名称"
className="rounded-lg border border-input px-3 py-2 text-sm outline-none focus:border-primary"
/>
<Button
variant="outline"
loading={action.isPending && action.variables?.action === 'subject'}
onClick={() => action.mutate({ action: 'subject', subject: subject.trim() })}
>
保存名称
</Button>
</div>
<div className="space-y-2">
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
placeholder="群描述"
className="w-full resize-y rounded-lg border border-input px-3 py-2 text-sm outline-none focus:border-primary"
/>
<Button
variant="outline"
loading={action.isPending && action.variables?.action === 'description'}
onClick={() => action.mutate({ action: 'description', description: description.trim() })}
>
保存描述
</Button>
</div>
<div className="rounded-xl border border-slate-200 p-3">
<p className="mb-2 text-xs font-semibold text-slate-700">添加成员</p>
<div className="flex gap-2">
<input
value={member}
onChange={(event) => setMember(event.target.value)}
placeholder="国家代码 + 手机号"
className="min-w-0 flex-1 rounded-lg border border-input px-3 py-2 text-sm outline-none focus:border-primary"
/>
<Button
disabled={!member.replace(/\D/g, '')}
onClick={() => action.mutate(
{ action: 'add', participants: [member] },
{ onSuccess: () => setMember('') },
)}
>
添加
</Button>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-semibold text-slate-700">成员({data?.participants?.length || 0})</p>
</div>
<div className="max-h-52 space-y-1 overflow-y-auto rounded-xl border border-slate-200 p-2">
{data?.participants?.map((participant) => (
<div key={participant.id} className="flex items-center gap-2 rounded-lg px-2 py-1.5 hover:bg-slate-50">
<span className="min-w-0 flex-1 truncate text-xs text-slate-700">
{participant.id.split('@')[0]}
{participant.admin && <span className="ms-2 text-blue-600">管理员</span>}
</span>
<button
type="button"
className="text-[11px] text-blue-600 hover:underline"
onClick={() => action.mutate({
action: participant.admin ? 'demote' : 'promote',
participants: [participant.id],
})}
>
{participant.admin ? '取消管理员' : '设为管理员'}
</button>
<button
type="button"
className="text-[11px] text-red-600 hover:underline"
onClick={() => action.mutate({ action: 'remove', participants: [participant.id] })}
>
移除
</button>
</div>
))}
</div>
</div>
<div className="rounded-xl bg-slate-50 p-3">
<div className="flex gap-2">
<Button variant="outline" onClick={updateInvite}>获取邀请链接</Button>
<Button
variant="outline"
onClick={() => action.mutate(
{ action: 'revoke-invite' },
{ onSuccess: () => { setInviteCode(''); void updateInvite() } },
)}
>
撤销并重建
</Button>
</div>
{inviteCode && (
<button
type="button"
onClick={() => {
void navigator.clipboard.writeText(inviteCode)
toast.success('邀请链接已复制')
}}
className="mt-2 block w-full truncate rounded-lg bg-white px-3 py-2 text-left text-xs text-blue-600 ring-1 ring-slate-200"
>
{inviteCode}
</button>
)}
</div>
</div>
)}
</Dialog>
)
}
/** 聊天顶部栏 */
function ChatHeader({
convId,
@@ -1079,6 +1398,7 @@ function ChatHeader({
wallpaper,
onWallpaperChange,
onTelegramAction,
onGroupManage,
onBack,
onInsight,
showInsightBtn,
@@ -1090,7 +1410,8 @@ function ChatHeader({
onViewModeChange: (v: ViewMode) => void
wallpaper: ChatWallpaper
onWallpaperChange: (wallpaper: ChatWallpaper) => void
onTelegramAction: (action: TelegramConversationAction) => void
onTelegramAction: (action: ConversationAction) => void
onGroupManage?: () => void
onBack?: () => void
onInsight?: () => void
showInsightBtn?: boolean
@@ -1098,11 +1419,29 @@ function ChatHeader({
if (!convId) return null
const c = detail?.customer
const conv = detail?.conversation
const isTelegramChannel = String(conv?.telegramChatType || c?.telegramChatType || '').toLowerCase() === 'channel'
const chatType = String(
conv?.channel === 'whatsapp'
? conv?.whatsappChatType || c?.whatsappChatType
: conv?.telegramChatType || c?.telegramChatType,
).toLowerCase()
const isTelegramChannel = conv?.channel === 'telegram' && chatType === 'channel'
const presenceLabel = conv?.presence === 'composing'
? '正在输入…'
: conv?.presence === 'recording'
? '正在录音…'
: conv?.presence === 'available'
? '在线'
: ''
return (
<div className="relative z-50 flex min-h-16 shrink-0 items-center gap-2 overflow-visible border-b border-slate-100 bg-white/95 px-2.5 py-2.5 backdrop-blur-xl sm:gap-3 sm:px-4">
{onBack && (
<Button variant="ghost" size="icon-sm" onClick={onBack}>
<Button
variant="ghost"
size="icon-sm"
onClick={onBack}
title="返回会话列表"
aria-label="返回会话列表"
>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
@@ -1112,7 +1451,16 @@ function ChatHeader({
<>
<div className="relative">
<Avatar name={c?.name ?? '?'} src={c?.avatarUrl} size="md" />
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-500" />
<span
className={cn(
'absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border-2 border-white',
conv?.presence === 'available'
? 'bg-emerald-500'
: ['composing', 'recording'].includes(conv?.presence || '')
? 'animate-pulse bg-sky-500'
: 'bg-slate-300',
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -1123,6 +1471,13 @@ function ChatHeader({
<p className="truncate text-[11px] text-slate-400">
{conv?.channel === 'email'
? conv.emailSubject || c?.email || 'Email 会话'
: conv?.channel === 'whatsapp'
? presenceLabel
|| (c?.phone
? `${c.phone} · WhatsApp`
: chatType === 'group'
? 'WhatsApp 群组'
: 'WhatsApp 会话')
: c?.telegramUsername
? `@${c.telegramUsername} · 在线`
: 'Telegram 会话'}
@@ -1157,14 +1512,27 @@ function ChatHeader({
onSelect: () => onWallpaperChange(item.value),
}))}
/>
{conv?.channel === 'telegram' && (
{['telegram', 'whatsapp'].includes(conv?.channel) && (
<Dropdown
trigger={(
<Button variant="ghost" size="icon-sm" title="Telegram 会话操作" aria-label="Telegram 会话操作">
<Button
variant="ghost"
size="icon-sm"
title={`${conv.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 会话操作`}
aria-label={`${conv.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 会话操作`}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
)}
items={[
...(conv.channel === 'whatsapp' && chatType === 'group' && onGroupManage
? [{
key: 'group-manage',
label: '群组管理',
icon: UserRound,
onSelect: onGroupManage,
}, 'separator' as const]
: []),
{
key: conv.isPinned ? 'unpin' : 'pin',
label: conv.isPinned ? '取消置顶' : '置顶会话',
@@ -1193,7 +1561,7 @@ function ChatHeader({
key: conv.isBlocked ? 'unblock' : 'block',
label: conv.isBlocked ? '解除拉黑' : '拉黑用户',
icon: Ban,
disabled: !c?.telegramUserId,
disabled: conv.channel === 'telegram' && !c?.telegramUserId,
onSelect: () => onTelegramAction(conv.isBlocked ? 'unblock' : 'block'),
},
'separator',
@@ -1202,16 +1570,16 @@ function ChatHeader({
key: 'clear',
label: '清空消息',
icon: Eraser,
onSelect: () => onTelegramAction('clear' as TelegramConversationAction),
onSelect: () => onTelegramAction('clear' as ConversationAction),
}]
: []),
...(String(conv.telegramChatType || c?.telegramChatType || '').toLowerCase() !== 'private'
...(chatType !== 'private'
? [{
key: 'leave',
label: '退出群组/频道',
icon: LogOut,
danger: true,
onSelect: () => onTelegramAction('leave' as TelegramConversationAction),
onSelect: () => onTelegramAction('leave' as ConversationAction),
}]
: []),
{
+4 -4
View File
@@ -65,7 +65,7 @@ export function KnowledgePage() {
<div className="p-4 md:p-6">
<PageHeader
title="企业知识库"
description={`${list.length} 条知识 · AI 回复与翻译的精准来源`}
description={`${list.length} 条知识 · 用于回复建议与翻译参考`}
actions={
<>
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
@@ -129,7 +129,7 @@ export function KnowledgePage() {
{tab === 'search' && (
<Card className="p-4">
<div className="flex gap-2">
<Input placeholder="输入跨境问题,测试知识库检索与 AI 答案生成" value={searchQ} onChange={(e) => setSearchQ(e.target.value)} className="flex-1" />
<Input placeholder="输入问题,测试知识库检索和答案生成" value={searchQ} onChange={(e) => setSearchQ(e.target.value)} className="flex-1" />
<Button loading={searchLoading} onClick={async () => {
if (!searchQ.trim()) return
setSearchLoading(true)
@@ -142,7 +142,7 @@ export function KnowledgePage() {
{searchResult && (
<div className="mt-4 space-y-3">
<Card className="p-3 bg-primary-50/30">
<p className="text-xs font-semibold text-muted-foreground mb-1">AI 答案</p>
<p className="text-xs font-semibold text-muted-foreground mb-1">参考答案</p>
<p className="text-sm">{searchResult.answer}</p>
</Card>
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p>
@@ -211,7 +211,7 @@ export function KnowledgePage() {
onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
className="block w-full rounded-md border border-input p-2 text-sm"
/>
<p className="mt-2 text-sm text-muted-foreground">支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML,文件内容由服务端真实解析并写入知识库。</p>
<p className="mt-2 text-sm text-muted-foreground">支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML,上传后自动解析并写入知识库。</p>
</Dialog>
</div>
)
+2 -2
View File
@@ -55,7 +55,7 @@ export function RisksPage() {
<div className="p-4 md:p-6">
<PageHeader
title="风险预警中心"
description="智能识别交易风险、客户流失信号与敏感表达,帮助团队主动应对"
description="查看交易风险、客户流失信号和敏感表达"
actions={
<PermissionGate permission="settings:manage">
<Button size="sm" variant="outline" onClick={() => navigate('/settings')}><Settings className="h-4 w-4" />风险识别设置</Button>
@@ -116,7 +116,7 @@ export function RisksPage() {
<span className="text-xs text-muted-foreground">{formatRelative(detail.createdAt)}</span>
</div>
<div>
<h4 className="text-sm font-semibold mb-1">AI 判断说明</h4>
<h4 className="text-sm font-semibold mb-1">判断依据</h4>
<p className="text-sm text-muted-foreground leading-relaxed">{detail.aiExplanation}</p>
</div>
<div>
+2 -2
View File
@@ -131,7 +131,7 @@ export function SettingsPage() {
<Field label="当前密码"><Input type="password" value={passwords.currentPassword} onChange={(e) => setPasswords((v) => ({ ...v, currentPassword: e.target.value }))} /></Field>
<Field label="新密码"><Input type="password" placeholder="至少10位,包含字母和数字" value={passwords.newPassword} onChange={(e) => setPasswords((v) => ({ ...v, newPassword: e.target.value }))} /></Field>
<Field label="确认新密码"><Input type="password" value={passwords.confirmPassword} onChange={(e) => setPasswords((v) => ({ ...v, confirmPassword: e.target.value }))} /></Field>
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />密码使用 bcrypt 加密;修改后所有旧登录 Token 会立即失效。</div>
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />修改密码后,其他已登录设备需要重新登录。</div>
<Button onClick={async () => {
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
try {
@@ -148,7 +148,7 @@ export function SettingsPage() {
<Card>
<CardHeader title="API 与 Webhook" />
<div className="space-y-3 p-4">
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,数据库仅保存 SHA-256 摘要。</p>
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,请立即复制并妥善保存。</p>
<div className="flex gap-2">
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
<Button variant="outline" onClick={async () => {
+2 -2
View File
@@ -66,8 +66,8 @@ export function TranslatePage() {
return (
<div className="p-4 md:p-6">
<PageHeader
title="AI 翻译中心"
description={`跨境商务场景专用翻译引擎,支持全球主流贸易国家和地区语言(可配置 ${supportedLanguageCount} 种)。`}
title="翻译中心"
description={`支持 ${supportedLanguageCount} 种已配置语言,可用于消息翻译与回复。`}
actions={<LiveDataBadge />}
/>
+102 -2
View File
@@ -52,8 +52,30 @@ export function getTelegramLoginState(channelId: string) {
)
}
export function syncTelegramChannel(channelId: string) {
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`)
export function startWhatsAppLogin(phone: string, name?: string) {
return http.post<ChannelConnection>('/api/channels/whatsapp/start', {
phone,
name,
acceptedUnofficialRisk: true,
})
}
export function getWhatsAppLoginState(channelId: string) {
return http.post<{
status: string
connectionState?: string
pairingCode?: string
qrDataUrl?: string
channel: ChannelConnection
}>(`/api/channels/whatsapp/${channelId}/state`)
}
export function setDefaultChannel(channelId: string) {
return http.post<ChannelConnection>(`/api/channels/${channelId}/default`)
}
export function syncChannel(channelId: string, options: { full?: boolean } = {}) {
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`, options)
}
/** POST /api/channels/test */
@@ -75,3 +97,81 @@ export function deleteChannel(id: string) {
export function getChannelLogs(id: string) {
return http.get<ChannelSyncLog[]>(`/api/channels/${id}/logs`)
}
export function getWhatsAppContacts(
channelId: string,
query: { query?: string; cursor?: string; limit?: number } = {},
) {
return http.get<{
items: Array<{ id: string; name: string; phone: string }>
nextCursor: string | null
total: number
}>(`/api/channels/${channelId}/whatsapp/contacts`, query)
}
export function updateWhatsAppProxy(channelId: string, url: string) {
return http.put<ChannelConnection>(`/api/channels/${channelId}/whatsapp/proxy`, {
url,
test: true,
})
}
export function updateWhatsAppProfile(
channelId: string,
payload: { name?: string; status?: string },
) {
return http.patch<ChannelConnection>(`/api/channels/${channelId}/whatsapp/profile`, payload)
}
export function updateWhatsAppProfilePicture(
channelId: string,
payload: { name: string; mimeType: string; base64: string },
) {
return http.post<ChannelConnection>(
`/api/channels/${channelId}/whatsapp/profile/picture`,
payload,
)
}
export function reauthorizeWhatsApp(channelId: string) {
return http.post<ChannelConnection>(`/api/channels/${channelId}/whatsapp/reauthorize`)
}
export function getWhatsAppDevices(channelId: string) {
return http.get<Array<{
id: string
name: string
platform: string
current: boolean
status: string
connectedAt?: string
}>>(`/api/channels/${channelId}/whatsapp/devices`)
}
export function terminateWhatsAppDevice(channelId: string, deviceId: string) {
return http.del<{ success: boolean; deviceId: string }>(
`/api/channels/${channelId}/whatsapp/devices/${encodeURIComponent(deviceId)}`,
)
}
export function getWhatsAppGatewayMetrics() {
return http.get<{
accounts: { total: number; connected: number; disconnected: number }
outbound: { queued: number; dead: number; sent: number; riskBackoff: number; averageLatencyMs: number }
webhooks: { queued: number; dead: number; delivered: number }
alerts: Array<{ severity: string; code: string; accountId: string; message: string }>
}>('/api/channels/whatsapp/metrics')
}
export function createWhatsAppBackup(channelId: string) {
return http.get<{ version: number; accountId: string; createdAt: string; blob: string }>(
`/api/channels/${channelId}/whatsapp/backup`,
)
}
export function restoreWhatsAppBackup(channelId: string, blob: string) {
return http.post<{ restored: boolean }>(
`/api/channels/${channelId}/whatsapp/backup/restore`,
{ blob },
)
}
+12
View File
@@ -17,10 +17,17 @@ export interface RequestOptions {
/** 由 auth store 注入,避免循环依赖 */
let tokenGetter: () => string | null = () => null
let unauthorizedHandler: (() => void) | null = null
let handlingUnauthorized = false
export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn
}
export function setUnauthorizedHandler(fn: () => void) {
unauthorizedHandler = fn
}
export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
const method = options.method ?? 'GET'
@@ -41,6 +48,11 @@ export async function api<T = unknown>(path: string, options: RequestOptions = {
})
if (res.status === 401) {
if (token && unauthorizedHandler && !handlingUnauthorized) {
handlingUnauthorized = true
unauthorizedHandler()
window.setTimeout(() => { handlingUnauthorized = false }, 1_000)
}
throw new ApiError(401, '登录状态已失效,请重新登录')
}
if (!res.ok) {
+124 -9
View File
@@ -10,6 +10,15 @@ export interface ConversationQuery {
userId?: string
}
export function normalizeMessage(message: Message): Message {
return {
...message,
originalText: String(message?.originalText ?? ''),
originalLang: String(message?.originalLang || 'und'),
reactions: Array.isArray(message?.reactions) ? message.reactions : [],
}
}
/** GET /api/conversations */
export function getConversations(query: ConversationQuery = {}) {
return http.get<{ list: ConversationListItem[]; total: number }>('/api/conversations', { ...query })
@@ -21,8 +30,26 @@ export function getConversation(id: string) {
}
/** GET /api/conversations/:id/messages */
export function getMessages(id: string) {
return http.get<Message[]>(`/api/conversations/${id}/messages`)
export async function getMessages(id: string) {
const messages = await http.get<Message[]>(`/api/conversations/${id}/messages`)
return Array.isArray(messages) ? messages.map(normalizeMessage) : []
}
export async function getMessagePage(
id: string,
query: { before?: string; after?: string; cursor?: string; limit?: number } = {},
) {
const page = await http.get<{
items: Message[]
beforeCursor: string | null
afterCursor: string | null
hasMoreBefore: boolean
hasMoreAfter: boolean
}>(`/api/conversations/${id}/messages/page`, query)
return {
...page,
items: Array.isArray(page?.items) ? page.items.map(normalizeMessage) : [],
}
}
export function updateMessage(id: string, messageId: string, payload: { isImportant?: boolean; internalNote?: string }) {
@@ -33,6 +60,13 @@ export function markConversationRead(id: string, messageIds?: string[]) {
return http.post<Conversation>(`/api/conversations/${id}/read`, { messageIds })
}
export function setConversationPresence(
id: string,
presence: 'composing' | 'recording' | 'paused' | 'available' | 'unavailable',
) {
return http.post<{ success: boolean; presence: string }>(`/api/conversations/${id}/presence`, { presence })
}
export function getMessageAttachment(id: string, messageId: string, attachmentId?: string) {
return http.get<{ dataUrl: string; mimeType: string; name: string }>(
`/api/conversations/${id}/messages/${messageId}/attachment`,
@@ -41,15 +75,40 @@ export function getMessageAttachment(id: string, messageId: string, attachmentId
}
/** POST /api/conversations/:id/messages */
export function sendMessage(id: string, payload: SendMessagePayload & { translatedText?: string; autoTranslate?: boolean }) {
return http.post<Message>(`/api/conversations/${id}/messages`, payload)
export function sendMessage(
id: string,
payload: SendMessagePayload & {
translatedText?: string
autoTranslate?: boolean
idempotencyKey?: string
clientMessageId?: string
},
) {
const idempotencyKey = payload.idempotencyKey || crypto.randomUUID()
return http.post<Message>(`/api/conversations/${id}/messages`, {
...payload,
idempotencyKey,
clientMessageId: payload.clientMessageId || idempotencyKey.replace(/-/g, '').slice(0, 20).toUpperCase(),
})
}
export function sendMedia(
id: string,
payload: { name: string; mimeType: string; base64: string; kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker' },
payload: {
name: string
mimeType: string
base64: string
kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker'
idempotencyKey?: string
clientMessageId?: string
},
) {
return http.post<unknown>(`/api/conversations/${id}/media`, payload)
const idempotencyKey = payload.idempotencyKey || crypto.randomUUID()
return http.post<unknown>(`/api/conversations/${id}/media`, {
...payload,
idempotencyKey,
clientMessageId: payload.clientMessageId || idempotencyKey.replace(/-/g, '').slice(0, 20).toUpperCase(),
})
}
export function sendAlbum(
@@ -60,11 +119,28 @@ export function sendAlbum(
}
/** PATCH /api/conversations/:id(标记已读/分配/状态) */
export type WhatsAppMessageAction =
| { action: 'react'; messageId: string; emoji: string }
| { action: 'edit'; messageId: string; text: string }
| { action: 'delete'; messageId: string }
| { action: 'forward'; messageId: string; destinationConversationId: string }
| { action: 'poll'; name: string; values: string[]; selectableCount?: number }
| { action: 'location'; latitude: number; longitude: number; name?: string; address?: string }
| { action: 'contact'; displayName: string; phone: string }
| { action: 'disappearing'; seconds: false | 0 | 86400 | 604800 | 7776000 }
export function runWhatsAppMessageAction(id: string, payload: WhatsAppMessageAction) {
return http.post<Message | { success: true }>(
`/api/conversations/${id}/messages/action`,
payload,
)
}
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
return http.patch<Conversation>(`/api/conversations/${id}`, payload)
}
export type TelegramConversationAction =
export type ConversationAction =
| 'clear'
| 'delete'
| 'pin'
@@ -78,9 +154,9 @@ export type TelegramConversationAction =
| 'block'
| 'unblock'
export function runTelegramConversationAction(
export function runConversationAction(
id: string,
action: TelegramConversationAction,
action: ConversationAction,
options: { seconds?: number } = {},
) {
return http.post<Conversation | { success: true; deleted: true }>(
@@ -99,3 +175,42 @@ export function openTelegramConversation(payload: {
payload,
)
}
export function openWhatsAppConversation(payload: {
identifier?: string
targetType: 'user' | 'group' | 'invite'
channelId?: string
subject?: string
participants?: string[]
}) {
return http.post<{ conversation: Conversation; customer: Customer }>(
'/api/conversations/whatsapp/open',
payload,
)
}
export interface WhatsAppGroupInfo {
id: string
name: string
description?: string
participants: Array<{
id: string
admin?: 'admin' | 'superadmin' | null
}>
}
export function getWhatsAppGroup(id: string) {
return http.get<WhatsAppGroupInfo>(`/api/conversations/${id}/whatsapp/group`)
}
export function runWhatsAppGroupAction(
id: string,
payload: {
action: 'add' | 'remove' | 'promote' | 'demote' | 'subject' | 'description' | 'invite-code' | 'revoke-invite'
participants?: string[]
subject?: string
description?: string
},
) {
return http.post<unknown>(`/api/conversations/${id}/whatsapp/group/action`, payload)
}
+20
View File
@@ -41,7 +41,18 @@ export interface Message {
productCard?: { name: string; sku: string; price: string }
replyToId?: string
replyPreview?: string
editedAt?: string
isDeleted?: boolean
deletedAt?: string
reactions?: Array<{
actor: string
emoji: string
fromMe?: boolean
updatedAt?: string
}>
telegramFileId?: number
whatsappMessageId?: string
whatsappContentType?: string
attachmentMimeType?: string
attachmentSize?: number
stickerEmoji?: string
@@ -74,12 +85,17 @@ export interface Conversation {
channelId?: string
telegramChatId?: string
telegramChatType?: string
whatsappJid?: string
whatsappChatType?: string
isPinned?: boolean
archived?: boolean
mutedUntil?: string | null
markedUnread?: boolean
isBlocked?: boolean
leftAt?: string
presence?: 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
presenceUpdatedAt?: string
lastSeenAt?: string
}
export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
@@ -106,6 +122,10 @@ export interface ChannelConnection {
smtpSecure?: boolean
syncDays?: number
lastVerifiedAt?: string
isDefault?: boolean
provider?: string
pairingCode?: string
qrDataUrl?: string
}
export interface ChannelSyncLog {
+82 -15
View File
@@ -11,6 +11,27 @@ async function login(page: Page, loginAccount = account, loginPassword = passwor
await expect(page).toHaveURL(/\/dashboard$/)
}
test('过期登录状态会回到登录页而不是显示网络错误', async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem('lx-auth', JSON.stringify({
state: {
token: 'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjF9.expired',
user: { id: 'expired-user', name: 'Expired User', role: 'admin' },
tenant: { id: 'expired-tenant', name: 'Expired Tenant', plan: 'pro' },
onboarded: true,
},
version: 0,
}))
})
await page.goto('/dashboard')
await expect(page).toHaveURL(/\/login(?:\?|$)/)
await expect(page.getByText('登录状态已过期,请重新登录')).toBeVisible()
await expect.poll(() => page.evaluate(() => {
const persisted = JSON.parse(window.localStorage.getItem('lx-auth') || '{}')
return persisted.state?.token ?? null
})).toBeNull()
})
test('Telegram 手机号验证码和二步密码登录向导', async ({ page, request }, testInfo) => {
const suffix = `${Date.now()}-${testInfo.project.name}`
const loginAccount = `channel-e2e-${suffix}@local.test`
@@ -20,8 +41,11 @@ test('Telegram 手机号验证码和二步密码登录向导', async ({ page, re
})
expect(response.ok()).toBeTruthy()
await login(page, loginAccount, loginPassword)
await page.goto('/channels?setup=telegram')
await expect(page.getByTestId('telegram-onboarding')).toContainText('先绑定 Telegram')
await page.goto('/channels?setup=choose')
await expect(page.getByTestId('channel-onboarding')).toContainText('不强制绑定 Telegram')
await expect(page.getByTestId('choose-whatsapp')).toBeVisible()
await expect(page.getByTestId('choose-email')).toBeVisible()
await page.getByTestId('choose-telegram').click()
await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
await expect(page.getByLabel('Telegram 验证码', { exact: true })).toHaveCount(0)
const sendTelegramCode = page.getByRole('button', { name: '发送 Telegram 验证码' })
@@ -29,10 +53,29 @@ test('Telegram 手机号验证码和二步密码登录向导', async ({ page, re
await sendTelegramCode.click()
await expect(page.getByText('请输入国际格式手机号,例如 +8613800138000')).toBeVisible()
await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
await expect(page.getByText('每个企业独立绑定账号')).toBeVisible()
await expect(page.getByText('可以继续添加其他工作账号', { exact: false })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('telegram-login-wizard.png'), fullPage: true })
})
test('WhatsApp 普通账号关联设备向导', async ({ page, request }, testInfo) => {
const suffix = `${Date.now()}-${testInfo.project.name}`
const loginAccount = `whatsapp-e2e-${suffix}@local.test`
const loginPassword = 'Lingxi-WhatsApp-2026!'
const response = await request.post('/api/auth/register', {
data: { name: 'WhatsApp E2E', account: loginAccount, password: loginPassword, company: 'WhatsApp E2E', industry: '跨境电商', teamSize: '1-10人' },
})
expect(response.ok()).toBeTruthy()
await login(page, loginAccount, loginPassword)
await page.goto('/channels?setup=choose')
await page.getByTestId('choose-whatsapp').click()
await expect(page.getByText('普通 WhatsApp 关联设备接入', { exact: true })).toBeVisible()
await expect(page.getByText('并非 Meta 官方 Business Cloud API', { exact: false })).toBeVisible()
await expect(page.getByRole('button', { name: '生成 WhatsApp 配对码' })).toBeVisible()
await page.getByRole('button', { name: '生成 WhatsApp 配对码' }).click()
await expect(page.getByText('请输入包含国家/地区代码的 WhatsApp 手机号')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('whatsapp-link-device-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`
@@ -47,11 +90,30 @@ test('企业邮箱 IMAP 和 SMTP 连接向导', async ({ page, request }, testIn
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.getByPlaceholder('邮箱密码或应用专用密码')).toHaveAttribute('type', 'password')
await expect(page.getByRole('button', { name: '验证并连接邮箱' })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('email-connection-wizard.png'), fullPage: true })
})
test('渠道真实消息统计与多 Telegram 账号入口', async ({ page }) => {
await login(page)
const responsePromise = page.waitForResponse((response) =>
response.url().endsWith('/api/channels') && response.request().method() === 'GET',
)
await page.goto('/channels')
const response = await responsePromise
expect(response.ok()).toBeTruthy()
const payload = await response.json()
for (const channel of payload.data) {
expect(Number.isInteger(channel.todayMessages)).toBeTruthy()
expect(channel.todayMessages).toBeGreaterThanOrEqual(0)
}
await page.getByRole('button', { name: 'Telegram', exact: true }).click()
await expect(page.getByText('连接 Telegram', { exact: true })).toBeVisible()
await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
await expect(page.getByText('可以继续添加其他工作账号', { exact: false })).toBeVisible()
})
test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
await login(page)
await expect(page).toHaveURL(/\/dashboard$/)
@@ -62,8 +124,8 @@ test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
await page.screenshot({ path: testInfo.outputPath('channels.png'), fullPage: true })
await page.goto('/inbox')
await page.getByRole('button', { name: '添加 Telegram 用户或群组' }).click()
await expect(page.getByRole('dialog')).toContainText('添加 Telegram 会话')
await page.getByRole('button', { name: '添加联系人或群组' }).click()
await expect(page.getByRole('dialog')).toContainText('添加联系人或群组')
await expect(page.getByLabel('用户名、手机号或用户 ID')).toBeVisible()
await page.getByRole('button', { name: '群组/频道', exact: true }).click()
await expect(page.getByLabel('群组/频道用户名或 Chat ID')).toBeVisible()
@@ -71,8 +133,9 @@ test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
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"])',
'[data-testid="conversation-item"][data-channel="telegram"][data-last-sender="agent"]:not([data-telegram-chat-type="channel"])',
).first()
await expect(privateConversation).toBeVisible()
await privateConversation.click()
await expect(page.getByTestId('message-content')).toHaveClass(/justify-end/)
const languageSwitcher = page.getByRole('radiogroup', { name: '消息显示语言' })
@@ -88,11 +151,15 @@ test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
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\]/)
if (testInfo.project.name !== 'mobile-chromium') {
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\]/)
} else {
await expect(page.getByRole('button', { name: '返回会话列表' })).toBeVisible()
}
await expect(page.getByTestId('conversation-insight-bar').locator('img')).toHaveCount(0)
await expect(page.getByTestId('conversation-insight-bar')).toContainText('AI 客户洞察')
await expect(page.getByTestId('conversation-insight-bar')).toContainText('客户洞察')
await page.getByRole('button', { name: 'Telegram 会话操作' }).click()
await expect(page.getByText(/置顶会话|取消置顶/)).toBeVisible()
await expect(page.getByText(/归档会话|移出归档/)).toBeVisible()
@@ -192,12 +259,12 @@ test('客户画像雷达与采购情报可视化', async ({ page }, testInfo) =>
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()
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()
else await expect(page.getByText('暂无标签,收到更多客户消息后将自动补充。')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('customer-profile-radar.png'), fullPage: true })
})
@@ -208,6 +275,6 @@ test('团队邀请和支付页不显示虚假成功', async ({ page }, testInfo)
await page.screenshot({ path: testInfo.outputPath('team.png'), fullPage: true })
await page.goto('/billing')
await expect(page.getByText('Stripe Checkout', { exact: false })).toBeVisible()
await expect(page.getByRole('heading', { name: '套餐与账单' })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('billing.png'), fullPage: true })
})
+4
View File
@@ -0,0 +1,4 @@
node_modules
npm-debug.log
.env
data
+23
View File
@@ -0,0 +1,23 @@
FROM node:20-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
RUN mkdir -p /data/auth /data/store \
&& chown -R node:node /data /app
USER node
EXPOSE 3200
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD node -e "fetch('http://127.0.0.1:3200/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
CMD ["node", "src/index.js"]
+2761
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "lingxi-whatsapp-gateway",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "灵犀跨境普通 WhatsApp 多账号网关",
"scripts": {
"start": "node src/index.js",
"check": "node --check src/index.js",
"test": "node --test test/api.test.js"
},
"dependencies": {
"@hapi/boom": "10.0.1",
"baileys": "7.0.0-rc13",
"express": "4.22.2",
"express-async-errors": "3.1.1",
"https-proxy-agent": "^7.0.6",
"multer": "2.2.0",
"pino": "9.6.0",
"qrcode": "1.5.4"
}
}
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
const token = 'gateway-test-token-12345678901234567890'
async function waitForReady(baseUrl, child) {
for (let attempt = 0; attempt < 80; attempt++) {
if (child.exitCode !== null) throw new Error(`网关提前退出,代码 ${child.exitCode}`)
try {
const response = await fetch(`${baseUrl}/health/ready`)
if (response.ok) return
} catch {
// 服务正在启动。
}
await new Promise((resolve) => setTimeout(resolve, 50))
}
throw new Error('等待 WhatsApp 网关启动超时')
}
test('网关执行健康检查、令牌鉴权和账号 CRUD', async () => {
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lingxi-wa-gateway-'))
const port = 32991
const baseUrl = `http://127.0.0.1:${port}`
const child = spawn(process.execPath, ['src/index.js'], {
cwd: path.resolve('.'),
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
WHATSAPP_GATEWAY_TOKEN: token,
LOG_LEVEL: 'silent',
},
stdio: 'ignore',
})
try {
await waitForReady(baseUrl, child)
const unauthorized = await fetch(`${baseUrl}/accounts`)
assert.equal(unauthorized.status, 401)
const headers = {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
}
const created = await fetch(`${baseUrl}/accounts`, {
method: 'POST',
headers,
body: JSON.stringify({ phoneNumber: '15550008888', name: 'Gateway Test' }),
})
assert.equal(created.status, 201)
const account = (await created.json()).data
assert.equal(account.phoneNumber, '15550008888')
assert.equal(account.status, 'created')
const duplicate = await fetch(`${baseUrl}/accounts`, {
method: 'POST',
headers,
body: JSON.stringify({ phoneNumber: '15550008888' }),
})
assert.equal(duplicate.status, 409)
const fetched = await fetch(`${baseUrl}/accounts/${account.id}`, { headers })
assert.equal(fetched.status, 200)
const webhookSecret = 'webhook-test-secret-that-must-be-encrypted'
const webhook = await fetch(`${baseUrl}/accounts/${account.id}/webhook`, {
method: 'PUT',
headers,
body: JSON.stringify({
url: 'http://127.0.0.1:39999/webhook',
secret: webhookSecret,
active: true,
}),
})
assert.equal(webhook.status, 200)
assert.equal((await webhook.json()).data.webhookSecretConfigured, true)
const persistedAccounts = await fs.readFile(path.join(dataDir, 'accounts.json'), 'utf8')
assert.equal(persistedAccounts.includes(webhookSecret), false)
const metrics = await fetch(`${baseUrl}/metrics`, { headers })
assert.equal(metrics.status, 200)
assert.equal((await metrics.json()).data.accounts.total, 1)
const invalidProxy = await fetch(`${baseUrl}/accounts/${account.id}/proxy`, {
method: 'PUT',
headers,
body: JSON.stringify({ url: 'socks5://127.0.0.1:1080' }),
})
assert.equal(invalidProxy.status, 400)
const unsafeReauthorize = await fetch(`${baseUrl}/accounts/${account.id}/reauthorize`, {
method: 'POST',
headers,
body: JSON.stringify({ confirm: 'wrong-account-id' }),
})
assert.equal(unsafeReauthorize.status, 400)
const backup = await fetch(`${baseUrl}/accounts/${account.id}/backup`, { headers })
assert.equal(backup.status, 200)
const backupData = (await backup.json()).data
assert.equal(backupData.accountId, account.id)
assert.match(backupData.blob, /^v1\./)
const removed = await fetch(`${baseUrl}/accounts/${account.id}`, {
method: 'DELETE',
headers,
})
assert.equal(removed.status, 200)
const missing = await fetch(`${baseUrl}/accounts/${account.id}`, { headers })
assert.equal(missing.status, 404)
} finally {
child.kill('SIGTERM')
await new Promise((resolve) => child.once('exit', resolve))
await fs.rm(dataDir, { recursive: true, force: true })
}
})