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 9804341463
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_SERVICE_PASSWORD=replace-with-random-service-password
TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id
TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret 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 PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
APP_PUBLIC_URL=http://127.0.0.1:8088 APP_PUBLIC_URL=http://127.0.0.1:8088
+22
View File
@@ -16,6 +16,21 @@ services:
retries: 10 retries: 10
restart: unless-stopped 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: api:
build: build:
context: ./server context: ./server
@@ -33,6 +48,10 @@ services:
TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required} TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required}
TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-} TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-}
TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required} 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} 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} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
@@ -56,6 +75,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
whatsapp-gateway:
condition: service_healthy
healthcheck: 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))"] 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 interval: 10s
@@ -77,3 +98,4 @@ services:
volumes: volumes:
lingxi_pgdata: lingxi_pgdata:
lingxi_whatsapp_data:
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"description": "灵犀跨境 - Telegram 跨境 AI 客户经营平台", "description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "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.notEqual(encrypted, 'app-password-123')
assert.match(encrypted, /^v1:/) assert.match(encrypted, /^v1:/)
assert.equal(decryptEmailPassword(encrypted), 'app-password-123') 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 { createBrowserRouter, Navigate, RouterProvider, useRouteError } from 'react-router-dom'
import { useAuthStore } from '@/app/store/auth' import { isSessionTokenExpired, useAuthStore } from '@/app/store/auth'
import { AppLayout } from '@/components/layout/AppLayout' import { AppLayout } from '@/components/layout/AppLayout'
import type { ReactNode } from 'react' import { useEffect, type ReactNode } from 'react'
import { LoginPage } from '@/pages/auth/LoginPage' import { LoginPage } from '@/pages/auth/LoginPage'
import { RegisterPage } from '@/pages/auth/RegisterPage' import { RegisterPage } from '@/pages/auth/RegisterPage'
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token) 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}</> return <>{children}</>
} }
function GuestOnly({ children }: { children: ReactNode }) { function GuestOnly({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token) 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}</> 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([ const router = createBrowserRouter([
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> }, { path: '/login', element: <GuestOnly><LoginPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> }, { path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
{ {
path: '/', path: '/',
errorElement: <RouteErrorFallback />,
element: ( element: (
<RequireAuth> <RequireAuth>
<AppLayout /> <AppLayout />
+21 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
import type { AuthSession, Tenant, User } from '@/types' import type { AuthSession, Tenant, User } from '@/types'
import type { RoleKey } from '@/config/roles' import type { RoleKey } from '@/config/roles'
import { setTokenGetter } from '@/services/api/client' import { setTokenGetter, setUnauthorizedHandler } from '@/services/api/client'
interface AuthState { interface AuthState {
token: string | null token: string | null
@@ -32,6 +32,26 @@ export const useAuthStore = create<AuthState>()(
// 让 API client 能读取 token(真实后端模式使用) // 让 API client 能读取 token(真实后端模式使用)
setTokenGetter(() => useAuthStore.getState().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 { export function useCurrentRole(): RoleKey {
return useAuthStore((s) => s.user?.role ?? 'viewer') 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', 'inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700',
className, className,
)} )}
title="指标由当前企业真实业务数据实时计算" title="指标实时更新"
> >
<Radio className="h-3 w-3" /> <Radio className="h-3 w-3" />
{text} {text}
@@ -37,7 +37,7 @@ export function profileRadarData(customer: Customer) {
return [ return [
{ dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' }, { 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.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' },
{ dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' }, { dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' },
{ dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' }, { dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' },
@@ -80,7 +80,7 @@ export function CustomerProfileRadar({
</span> </span>
<div> <div>
<p className="text-sm font-semibold tracking-tight text-slate-950">客户价值雷达</p> <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>
</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)}> <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} /> <PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
<Tooltip <Tooltip
formatter={(value: number) => [`${value} / 100`, '画像指数']} formatter={(value: number) => [`${value} / 100`, '画像指数']}
labelFormatter={(label) => `${label} · AI 评估`} labelFormatter={(label) => `${label} · 参考值`}
contentStyle={{ contentStyle={{
borderRadius: 14, borderRadius: 14,
border: '1px solid #dbeafe', border: '1px solid #dbeafe',
@@ -135,7 +135,7 @@ export function CustomerProfileRadar({
}} }}
/> />
<Radar <Radar
name="客户画像光晕" name="客户画像"
dataKey="score" dataKey="score"
isAnimationActive={false} isAnimationActive={false}
stroke="#60a5fa" stroke="#60a5fa"
+4 -4
View File
@@ -33,7 +33,7 @@ export function AiReplies({
mutationFn: () => generateReplies(conversationId), mutationFn: () => generateReplies(conversationId),
onSuccess: (data) => { onSuccess: (data) => {
setItems(Array.isArray(data) ? data : []) setItems(Array.isArray(data) ? data : [])
toast.success('AI 回复建议已生成', '根据客户画像、知识库和历史成功回复综合生成') toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复')
}, },
onError: (e) => toast.error(errorMessage(e)), onError: (e) => toast.error(errorMessage(e)),
}) })
@@ -43,7 +43,7 @@ export function AiReplies({
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h4 className="flex items-center gap-2 text-xs font-semibold text-slate-700"> <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> <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> </h4>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@@ -61,7 +61,7 @@ export function AiReplies({
'生成回复建议' '生成回复建议'
)} )}
</Button> </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" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -169,7 +169,7 @@ export function AiReplies({
{!items && !mutation.isPending && ( {!items && !mutation.isPending && (
<div className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5"> <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> <span className="hidden text-[10px] font-medium text-primary sm:inline">不会自动发送</span>
</div> </div>
)} )}
+6 -1
View File
@@ -75,7 +75,12 @@ export function Composer({
setSending(true) setSending(true)
try { try {
await onMedia(Array.from(files), kind) 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 { } catch {
toast.error('媒体发送失败') toast.error('媒体发送失败')
} finally { } finally {
@@ -23,7 +23,7 @@ export function ConversationInsightBar({
onOpenProfile: () => void onOpenProfile: () => void
}) { }) {
const stage = validStages.has(customer.stage) ? customer.stage : 'initial' 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 ( return (
<div <div
@@ -36,7 +36,7 @@ export function ConversationInsightBar({
</span> </span>
<div className="flex min-w-0 flex-1 items-center gap-2"> <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"> <span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
AI 客户洞察 客户洞察
</span> </span>
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p> <p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
</div> </div>
+9 -6
View File
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react' 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 { useQuery } from '@tanstack/react-query'
import { getTeam } from '@/services/api/org' import { getTeam } from '@/services/api/org'
import type { ConversationListItem } from '@/services/api/conversations' import type { ConversationListItem } from '@/services/api/conversations'
@@ -26,7 +26,7 @@ const FILTERS = [
] ]
export function ConversationList({ export function ConversationList({
items, items = [],
loading, loading,
activeId, activeId,
onSelect, onSelect,
@@ -75,8 +75,8 @@ export function ConversationList({
<button <button
type="button" type="button"
onClick={onAdd} onClick={onAdd}
title="添加 Telegram 用户或群组" title="添加联系人或群组"
aria-label="添加 Telegram 用户或群组" 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" 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" /> <Plus className="h-4 w-4" />
@@ -96,6 +96,7 @@ export function ConversationList({
{[ {[
{ value: '', label: '全部', icon: null }, { value: '', label: '全部', icon: null },
{ value: 'telegram', label: 'Telegram', icon: Send }, { value: 'telegram', label: 'Telegram', icon: Send },
{ value: 'whatsapp', label: 'WhatsApp', icon: MessageCircle },
{ value: 'email', label: 'Email', icon: Mail }, { value: 'email', label: 'Email', icon: Mail },
].map((item) => ( ].map((item) => (
<button <button
@@ -153,9 +154,10 @@ export function ConversationList({
const active = item.id === activeId const active = item.id === activeId
const intent = intentLevelMeta(item.intentLevel) const intent = intentLevelMeta(item.intentLevel)
const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel' const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel'
const supportsSwipeActions = item.channel === 'telegram' || item.channel === 'whatsapp'
return ( return (
<div key={item.id} className="relative mb-0.5 overflow-hidden rounded-xl"> <div key={item.id} className="relative mb-0.5 overflow-hidden rounded-xl">
{item.channel === 'telegram' && ( {supportsSwipeActions && (
<div <div
data-testid="conversation-swipe-actions" data-testid="conversation-swipe-actions"
aria-hidden={swipedId !== item.id && touchStart.current?.id !== item.id} aria-hidden={swipedId !== item.id && touchStart.current?.id !== item.id}
@@ -189,8 +191,9 @@ export function ConversationList({
data-conversation-id={item.id} data-conversation-id={item.id}
data-channel={item.channel} data-channel={item.channel}
data-telegram-chat-type={item.telegramChatType || ''} data-telegram-chat-type={item.telegramChatType || ''}
data-last-sender={item.lastMessage?.sender || ''}
onTouchStart={(event) => { onTouchStart={(event) => {
if (item.channel !== 'telegram') return if (!supportsSwipeActions) return
const touch = event.touches[0] const touch = event.touches[0]
touchStart.current = { x: touch.clientX, y: touch.clientY, id: item.id } touchStart.current = { x: touch.clientX, y: touch.clientY, id: item.id }
if (swipedId !== item.id) setDragOffset(0) if (swipedId !== item.id) setDragOffset(0)
+6 -6
View File
@@ -70,7 +70,7 @@ export function InsightPanel({
conversationId: conversation.id, conversationId: conversation.id,
ownerId: user?.id || '', ownerId: user?.id || '',
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
note: `由 AI 客户洞察建议创建:${title}`, note: `由客户洞察建议创建:${title}`,
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] }) queryClient.invalidateQueries({ queryKey: ['tasks'] })
@@ -84,7 +84,7 @@ export function InsightPanel({
try { try {
const result = await analyzeConversation(conversation.id) const result = await analyzeConversation(conversation.id)
setAnalysis(result.summary) 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 { } catch {
toast.error('分析失败,请稍后重试') toast.error('分析失败,请稍后重试')
} finally { setAnalyzing(false) } } 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="rounded-2xl border border-slate-200/80 bg-white p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-semibold tracking-tight text-slate-900">AI 洞察摘要</p> <p className="text-sm font-semibold tracking-tight text-slate-900">会话摘要</p>
<p className="text-[11px] text-slate-400">基于完整对话实时更新</p> <p className="text-[11px] text-slate-400">根据已同步的会话内容生成</p>
</div> </div>
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}> <Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'} {analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
@@ -193,7 +193,7 @@ export function InsightPanel({
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span> <span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
</div> </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 提取关键信息 */} {/* AI 提取关键信息 */}
{Object.values(customer.aiProfile || {}).some(Boolean) && ( {Object.values(customer.aiProfile || {}).some(Boolean) && (
@@ -240,7 +240,7 @@ export function InsightPanel({
{/* 推荐动作 */} {/* 推荐动作 */}
<div className="p-4"> <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"> <div className="mt-2 space-y-2">
{customer.aiProfile.nextActions.map((a) => ( {customer.aiProfile.nextActions.map((a) => (
<button <button
+63 -20
View File
@@ -19,9 +19,11 @@ import {
Download, Download,
FileText, FileText,
LoaderCircle, LoaderCircle,
SmilePlus,
Trash2,
} from 'lucide-react' } from 'lucide-react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { Message } from '@/types' import type { ChannelType, Message } from '@/types'
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
import { toast } from '@/app/store/toast' import { toast } from '@/app/store/toast'
import { formatTime } from '@/utils/format' import { formatTime } from '@/utils/format'
@@ -82,8 +84,11 @@ export function MessageBubble({
onConvertToTask, onConvertToTask,
onAddToKnowledge, onAddToKnowledge,
onAddNote, onAddNote,
onReact,
onDelete,
customerName, customerName,
customerAvatar, customerAvatar,
channel,
}: { }: {
msg: Message msg: Message
viewMode: ViewMode viewMode: ViewMode
@@ -92,33 +97,43 @@ export function MessageBubble({
onConvertToTask: () => void onConvertToTask: () => void
onAddToKnowledge: () => void onAddToKnowledge: () => void
onAddNote: () => void onAddNote: () => void
onReact?: (emoji: string) => void
onDelete?: () => void
customerName?: string customerName?: string
customerAvatar?: string customerAvatar?: string
channel?: ChannelType
}) { }) {
const [showActions, setShowActions] = useState(false) const [showActions, setShowActions] = useState(false)
const [translationExpanded, setTranslationExpanded] = useState(false) const [translationExpanded, setTranslationExpanded] = useState(false)
const isMe = msg.sender === 'agent' const isMe = msg.sender === 'agent'
const isSystem = msg.sender === 'system' const isSystem = msg.sender === 'system'
const originalText = String(msg.originalText || '')
const rawTranslation = String(msg.translatedText || '') const rawTranslation = String(msg.translatedText || '')
const translationCorrupted = const translationCorrupted =
/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) || /(?:^|\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 translatedText = translationCorrupted ? '' : rawTranslation
const hasTranslation = translatedText && translatedText !== msg.originalText const hasTranslation = translatedText && translatedText !== originalText
const outgoingChineseNote = isMe const outgoingChineseNote = isMe
? String( ? String(
msg.translationCn || msg.translationCn ||
(hasTranslation && /[\u3400-\u9fff]/u.test(msg.originalText) ? msg.originalText : ''), (hasTranslation && /[\u3400-\u9fff]/u.test(originalText) ? originalText : ''),
).trim() ).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 longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6
const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang)) const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang))
const sourceLanguage = hasKnownLanguage ? languageName(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({ 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), queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
enabled: Boolean(msg.telegramFileId), enabled: hasProviderAttachment,
staleTime: Infinity, staleTime: Infinity,
retry: 1, retry: 1,
}) })
@@ -126,7 +141,7 @@ export function MessageBubble({
if (isSystem) { if (isSystem) {
return ( return (
<div className="flex justify-center py-2"> <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> </div>
) )
} }
@@ -156,10 +171,18 @@ export function MessageBubble({
) )
const renderContent = () => { 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') { if (msg.type !== 'text') {
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' } const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
return msg.originalText ? ( return originalText ? (
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{msg.originalText}</p> <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"> <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" /> <span className="h-1.5 w-1.5 rounded-full bg-primary-400" />
@@ -167,8 +190,8 @@ export function MessageBubble({
</div> </div>
) )
} }
if (!String(msg.originalText || '').trim()) { if (!originalText.trim()) {
return <p className="text-xs text-slate-500">Telegram 服务通知</p> return <p className="text-xs text-slate-500">{providerName} 服务通知</p>
} }
if ( if (
(viewMode === 'bilingual' || viewMode === 'translated') && (viewMode === 'bilingual' || viewMode === 'translated') &&
@@ -178,7 +201,7 @@ export function MessageBubble({
) { ) {
return ( return (
<div className="space-y-2.5"> <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"> <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" /> <Languages className="h-3.5 w-3.5 text-primary" />
{translationCorrupted {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"> <span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'} {hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
</span> </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>
<div className="border-t border-border/60 pt-2"> <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"> <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> </span>
)} )}
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}> <p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
{msg.originalText} {originalText}
</p> </p>
</div> </div>
) )
@@ -294,12 +317,18 @@ export function MessageBubble({
} }
const moreActions = [ 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 }, { 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: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant }, { key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask }, { key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
{ key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge }, { key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge },
...(channel === 'whatsapp' && isMe && onDelete
? [{ key: 'delete', label: '撤回消息', icon: Trash2, danger: true, onSelect: onDelete }]
: []),
] ]
return ( return (
@@ -315,7 +344,7 @@ export function MessageBubble({
> >
{!isMe && ( {!isMe && (
<Avatar <Avatar
name={customerName || msg.senderName || 'Telegram 用户'} name={customerName || msg.senderName || `${providerName} 用户`}
src={customerAvatar} src={customerAvatar}
size="xs" size="xs"
className="order-1 mt-1" className="order-1 mt-1"
@@ -348,9 +377,9 @@ export function MessageBubble({
</div> </div>
)} )}
{renderContent()} {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"> <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> </div>
)} )}
{attachment && msg.type === 'image' && ( {attachment && msg.type === 'image' && (
@@ -378,7 +407,7 @@ export function MessageBubble({
<Download className="h-4 w-4 text-muted-foreground" /> <Download className="h-4 w-4 text-muted-foreground" />
</a> </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> <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) => ( {msg.emailAttachments?.map((emailAttachment) => (
@@ -389,11 +418,25 @@ export function MessageBubble({
attachment={emailAttachment} 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>
{/* 底栏:时间 + 状态 */} {/* 底栏:时间 + 状态 */}
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}> <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> <span>{formatTime(msg.createdAt)}</span>
{msg.editedAt && !msg.isDeleted && <span>已编辑</span>}
{isMe && statusIcon()} {isMe && statusIcon()}
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />} {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" />} {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', key: 'pro',
name: '专业版', name: '专业版',
tagline: '5—20 人团队的 AI 经营中枢', tagline: '多渠道客户沟通与团队协作',
priceNote: '¥49,800/年', priceNote: '¥49,800/年',
monthlyPrice: null, monthlyPrice: null,
yearlyPrice: 49800, yearlyPrice: 49800,
@@ -66,9 +66,9 @@ export const PRICING_PLANS: PricingPlan[] = [
features: [ features: [
'包含标准版全部能力', '包含标准版全部能力',
'客户情感分析与意图预测', '客户情感分析与意图预测',
'AI 智能回复建议(多策略)', '多策略回复建议',
'风险预警与文化敏感检测', '风险预警与文化敏感检测',
'老板经营看板', '业务数据看板',
], ],
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 }, 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' }, admin: { label: '企业管理员', description: '管理企业、成员、权限、渠道、套餐与系统设置', color: 'bg-violet-100 text-violet-700' },
manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' }, manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' },
sales: { label: '外贸业务员', description: '查看分配给自己的客户、消息与跟进任务', color: 'bg-teal-100 text-teal-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' }, 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"> <div className="p-4 md:p-6">
<PageHeader <PageHeader
title="数据分析中心" title="数据分析中心"
description="企业经营数据全景 · 基于当前企业实时数据" description="查看客户、消息、跟进与转化数据"
actions={<LiveDataBadge />} actions={<LiveDataBadge />}
extra={ extra={
<div className="flex flex-wrap items-center gap-2"> <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> <ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
</div> </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"> <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.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.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="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="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="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.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>
</div> </div>
) )
+18 -11
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { z } from 'zod' import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
@@ -28,6 +28,7 @@ type FormValues = z.infer<typeof schema>
export function LoginPage() { export function LoginPage() {
const { t, locale, setLocale } = useI18n() const { t, locale, setLocale } = useI18n()
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const setSession = useAuthStore((s) => s.setSession) const setSession = useAuthStore((s) => s.setSession)
const [mode, setMode] = useState<'code' | 'password'>('password') const [mode, setMode] = useState<'code' | 'password'>('password')
const [remember, setRemember] = useState(true) const [remember, setRemember] = useState(true)
@@ -42,6 +43,12 @@ export function LoginPage() {
formState: { errors }, formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } }) } = 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) => { const doLogin = async (values: FormValues) => {
if (!agreed) { if (!agreed) {
toast.warning('请先勾选并同意《隐私政策》和《服务协议》') toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
@@ -94,16 +101,16 @@ export function LoginPage() {
</div> </div>
<div className="space-y-8"> <div className="space-y-8">
<h1 className="text-2xl font-semibold leading-snug"> <h1 className="text-2xl font-semibold leading-snug">
把分散在全球渠道的客户对话, 集中处理客户消息,
<br /> <br />
沉淀为可成交的企业资产。 更快回复和跟进。
</h1> </h1>
<div className="space-y-4"> <div className="space-y-4">
{[ {[
{ icon: MessageCircle, text: 'WhatsApp / Telegram / 邮件 / 独立站,统一收件箱' }, { icon: MessageCircle, text: '集中处理 WhatsApp、Telegram、邮件和独立站消息' },
{ icon: Languages, text: 'AI 翻译与行业术语,跨语种沟通专业自然' }, { icon: Languages, text: '消息翻译与双语对照,支持行业术语' },
{ icon: ShieldCheck, text: '情感分析、风险预警、文化敏感检测' }, { icon: ShieldCheck, text: '识别客户情绪、沟通意图和风险信号' },
{ icon: UsersRound, text: '客户自动建档、意向评分与团队协同跟进' }, { icon: UsersRound, text: '管理客户资料、意向评分和团队跟进' },
].map((f) => ( ].map((f) => (
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100"> <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"> <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"> <div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p> <p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p> <p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
<p>2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。</p> <p>2. 我们按照已公布的安全措施处理会话数据;涉及第三方服务时,处理范围以隐私政策及相关服务条款为准。</p>
<p>3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。</p> <p>3. 数据导出、删除和账号注销申请按照正式发布的隐私政策处理。</p>
<p>4. 使用 AI 功能时,相关内容仅用于为您生成翻译与回复建议,不会用于其他用途。</p> <p>4. 使用翻译和回复建议时,相关内容可能发送至已配置的模型服务商处理,具体范围以隐私政策为准。</p>
</div> </div>
</Dialog> </Dialog>
+1 -1
View File
@@ -51,7 +51,7 @@ export function RegisterPage() {
const session = await registerApi(values) const session = await registerApi(values)
setSession(session) setSession(session)
toast.success('企业创建成功', '接下来请完成企业初始化配置') toast.success('企业创建成功', '接下来请完成企业初始化配置')
navigate('/channels?setup=telegram') navigate('/channels?setup=choose')
} catch (e) { } catch (e) {
toast.error(errorMessage(e)) toast.error(errorMessage(e))
} finally { } finally {
+1 -1
View File
@@ -33,7 +33,7 @@ export function BillingPage() {
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<PageHeader title="套餐与账单" description="通过 Stripe Checkout 管理真实订阅与支付" /> <PageHeader title="套餐与账单" description="管理套餐、订阅和支付记录" />
{info && ( {info && (
<Card className="mb-4 p-4"> <Card className="mb-4 p-4">
+240 -75
View File
@@ -1,20 +1,23 @@
import { useEffect, useRef, useState } from 'react' import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
Activity, Activity,
ArrowUpRight, ArrowUpRight,
AlertTriangle,
Cable, Cable,
CheckCircle2, CheckCircle2,
History, History,
Mail, Mail,
MessageCircle,
Plus,
MessageSquareText, MessageSquareText,
Pause, Pause,
Play, Play,
RefreshCw, RefreshCw,
Settings, Settings,
ShieldCheck, ShieldCheck,
Smartphone, Star,
Trash2, Trash2,
Workflow, Workflow,
} from 'lucide-react' } from 'lucide-react'
@@ -34,7 +37,8 @@ import { ChannelIcon } from '@/components/common/ChannelIcon'
import { import {
getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs, getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs,
connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword, connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword,
getTelegramLoginState, syncTelegramChannel, getTelegramLoginState, syncChannel, startWhatsAppLogin, getWhatsAppLoginState,
setDefaultChannel,
} from '@/services/api/channels' } from '@/services/api/channels'
import { CHANNEL_META } from '@/config/constants' import { CHANNEL_META } from '@/config/constants'
import { formatRelative } from '@/utils/format' import { formatRelative } from '@/utils/format'
@@ -60,13 +64,21 @@ export function ChannelsPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const queryClient = useQueryClient() 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 [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
const [telegramAccount, setTelegramAccount] = useState('') const [telegramAccount, setTelegramAccount] = useState('')
const [telegramCode, setTelegramCode] = useState('') const [telegramCode, setTelegramCode] = useState('')
const [telegramPassword, setTelegramPassword] = useState('') const [telegramPassword, setTelegramPassword] = useState('')
const [loginChannelId, setLoginChannelId] = useState('') const [loginChannelId, setLoginChannelId] = useState('')
const [authState, setAuthState] = 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 [connecting, setConnecting] = useState(false)
const [emailPreset, setEmailPreset] = useState<keyof typeof EMAIL_PRESETS>('custom') const [emailPreset, setEmailPreset] = useState<keyof typeof EMAIL_PRESETS>('custom')
const [emailForm, setEmailForm] = useState({ const [emailForm, setEmailForm] = useState({
@@ -85,7 +97,6 @@ export function ChannelsPage() {
const [logDrawer, setLogDrawer] = useState<string | null>(null) const [logDrawer, setLogDrawer] = useState<string | null>(null)
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null) const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([]) const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
const setupOpenedRef = useRef(false)
const loadLogs = async (chId: string) => { const loadLogs = async (chId: string) => {
const logs = await getChannelLogs(chId) const logs = await getChannelLogs(chId)
@@ -122,7 +133,7 @@ export function ChannelsPage() {
const completeTelegramBinding = async (channelId: string) => { const completeTelegramBinding = async (channelId: string) => {
let syncSummary = '' let syncSummary = ''
try { try {
const result = await syncTelegramChannel(channelId) const result = await syncChannel(channelId)
syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息` syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息`
} catch (error) { } catch (error) {
syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}` syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}`
@@ -155,15 +166,48 @@ export function ChannelsPage() {
} }
} }
useEffect(() => { const openWhatsAppWizard = async (channel?: ChannelConnection) => {
if (!channels || setupOpenedRef.current || searchParams.get('setup') !== 'telegram') return setWhatsAppAccount(channel?.account || '')
setupOpenedRef.current = true setWhatsAppChannelId(channel && channel.id !== 'whatsapp-available' ? channel.id : '')
const telegram = channels.find((item) => item.type === 'telegram') setWhatsAppPairingCode(channel?.pairingCode || '')
void openTelegramWizard(telegram) 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 }) setSearchParams({}, { replace: true })
// 新注册后的 setup 参数只消费一次,避免查询刷新时重复打开向导。 if (type === 'telegram') {
// eslint-disable-next-line react-hooks/exhaustive-deps void openTelegramWizard(channel)
}, [channels, searchParams, setSearchParams]) } else if (type === 'whatsapp') {
void openWhatsAppWizard(channel)
} else {
applyEmailPreset('custom')
setWizard({ open: true, type: 'email', step: 0 })
}
}
const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => { const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => {
setEmailPreset(value) setEmailPreset(value)
@@ -180,8 +224,7 @@ export function ChannelsPage() {
} }
const connectedCount = list.filter((channel) => channel.status === 'connected').length const connectedCount = list.filter((channel) => channel.status === 'connected').length
const telegramChannel = list.find((channel) => channel.type === 'telegram') const setupChoice = searchParams.get('setup') === 'choose'
const telegramConnected = telegramChannel?.status === 'connected'
const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0) const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0)
const latestSync = list const latestSync = list
.map((channel) => channel.lastSyncAt) .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="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 className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
</span> </span>
Unified communication hub 渠道连接
</div> </div>
<h1 className="text-2xl font-bold tracking-[-0.035em] text-slate-950 md:text-3xl">渠道与账号</h1> <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"> <p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
将 Telegram 即时消息与企业邮箱汇入同一个客户工作台,统一翻译、画像、分配和跟进。 在一个工作台管理 WhatsApp、Telegram 与企业邮箱消息。
</p> </p>
</div> </div>
<div className="grid grid-cols-3 gap-2 sm:min-w-[460px]"> <div className="grid grid-cols-3 gap-2 sm:min-w-[460px]">
@@ -228,66 +271,88 @@ export function ChannelsPage() {
description="连接状态、同步进度与安全设置" description="连接状态、同步进度与安全设置"
/> />
{!telegramConnected && ( {setupChoice && (
<Card <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)]" 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 className="p-5 lg:p-6">
<div> <Badge variant="primary">企业创建成功 · 渠道可稍后配置</Badge>
<div className="flex flex-wrap items-center gap-2"> <h2 className="mt-3 text-xl font-bold tracking-tight text-slate-950">选择第一个客户沟通渠道</h2>
<Badge variant="primary">首次配置 · 约 2 分钟</Badge> <p className="mt-1.5 text-sm leading-6 text-slate-600">
<span className="text-xs font-medium text-slate-500">使用你自己的 Telegram 工作账号</span> 不强制绑定 Telegram。你可以连接 WhatsApp、Telegram 或企业邮箱,也可以先进入系统,之后再添加多个账号。
</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> </p>
<div className="mt-4 grid gap-2 sm:grid-cols-3"> <div className="mt-5 grid gap-3 md:grid-cols-3">
{[ {[
{ icon: Smartphone, step: '01', title: '验证手机号', text: '例如 +8613800138000' }, { type: 'telegram' as const, icon: Cable, title: 'Telegram', text: '手机号、验证码与可选二步密码' },
{ icon: ShieldCheck, step: '02', title: '完成安全验证', text: '验证码与二步密码' }, { type: 'whatsapp' as const, icon: MessageCircle, title: 'WhatsApp', text: '普通账号通过关联设备配对' },
{ icon: RefreshCw, step: '03', title: '自动同步消息', text: '历史会话与实时消息' }, { type: 'email' as const, icon: Mail, title: '企业邮箱', text: 'IMAP 收信与 SMTP 发信' },
].map((item) => ( ].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"> <button
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-50 text-primary"> type="button"
<item.icon className="h-4 w-4" /> 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> </span>
<div className="min-w-0"> <p className="mt-3 text-sm font-bold text-slate-900">{item.title}</p>
<p className="text-xs font-semibold text-slate-800">{item.step} · {item.title}</p> <p className="mt-1 text-xs leading-5 text-slate-500">{item.text}</p>
<p className="mt-0.5 truncate text-[10px] text-slate-400">{item.text}</p> </button>
</div>
</div>
))} ))}
</div> </div>
</div> <div className="mt-4 flex justify-end">
<Button <Button variant="ghost" onClick={() => setSearchParams({}, { replace: true })}>
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> </Button>
</div> </div>
</div>
</Card> </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"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{list.map((ch) => { {list.map((ch) => {
const meta = CHANNEL_META[ch.type] const meta = CHANNEL_META[ch.type]
const st = STATUS_MAP[ch.status] const st = STATUS_MAP[ch.status]
return ( 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)]"> <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="p-5">
<div className="flex items-start justify-between"> <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"> <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" /> <ChannelIcon channel={ch.type} size="lg" />
</div> </div>
<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> <Badge className={st.color}>{st.label}</Badge>
</div> </div>
</div>
<div className="mt-4"> <div className="mt-4">
<p className="text-base font-semibold tracking-tight text-slate-950">{meta.name}</p> <p className="text-base font-semibold tracking-tight text-slate-950">{meta.name}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{meta.desc}</p> <p className="mt-1 text-xs leading-5 text-slate-500">{meta.desc}</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> <p className="mt-0.5 text-sm font-semibold text-slate-800">{Number(ch.todayMessages || 0)} 条</p>
</div> </div>
<div className="rounded-xl bg-slate-50 px-3 py-2.5"> <div className="rounded-xl bg-slate-50 px-3 py-2.5">
<p className="text-[10px] font-medium text-slate-400">同步状态</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.lastSyncAt ? formatRelative(ch.lastSyncAt) : '等待同步'}</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> </div>
<div className="mt-3 flex flex-wrap gap-1.5"> <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"> <div className="mt-5 flex flex-wrap items-center gap-1 border-t border-slate-100 pt-4">
{ch.status === 'connected' ? ( {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 () => { <Button size="sm" variant="outline" onClick={async () => {
try { try {
await updateChannel(ch.id, { status: 'paused' }) await updateChannel(ch.id, { status: 'paused' })
toast.success('渠道已暂停,实时 Webhook 已停用') toast.success('渠道已暂停接收实时消息')
queryClient.invalidateQueries({ queryKey: ['channels'] }) queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) { } catch (error) {
toast.error(errorMessage(error)) toast.error(errorMessage(error))
} }
}}><Pause className="h-3 w-3" />暂停</Button> }}><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> <Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
</> </>
) : ch.status === 'paused' ? ( ) : ch.status === 'paused' ? (
<Button size="sm" variant="outline" onClick={async () => { <Button size="sm" variant="outline" onClick={async () => {
try { try {
await updateChannel(ch.id, { status: 'connected' }) await updateChannel(ch.id, { status: 'connected' })
toast.success('渠道已恢复,实时 Webhook 已启用') toast.success('渠道已恢复接收实时消息')
queryClient.invalidateQueries({ queryKey: ['channels'] }) queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) { } catch (error) {
toast.error(errorMessage(error)) toast.error(errorMessage(error))
@@ -357,6 +430,8 @@ export function ChannelsPage() {
if (ch.type === 'email') { if (ch.type === 'email') {
applyEmailPreset('custom') applyEmailPreset('custom')
setWizard({ open: true, type: ch.type, step: 0 }) setWizard({ open: true, type: ch.type, step: 0 })
} else if (ch.type === 'whatsapp') {
void openWhatsAppWizard(ch)
} else { } else {
void openTelegramWizard(ch) void openTelegramWizard(ch)
} }
@@ -370,17 +445,27 @@ export function ChannelsPage() {
if (ch.type === 'email') { if (ch.type === 'email') {
applyEmailPreset('custom') applyEmailPreset('custom')
setWizard({ open: true, type: ch.type, step: 0 }) setWizard({ open: true, type: ch.type, step: 0 })
} else if (ch.type === 'whatsapp') {
void openWhatsAppWizard(ch)
} else { } else {
void openTelegramWizard(ch) void openTelegramWizard(ch)
} }
}} }}
> >
{ch.type === 'email' ? <Mail className="h-3 w-3" /> : <Cable className="h-3 w-3" />} {ch.type === 'email'
{ch.type === 'telegram' && ch.id !== 'telegram-available' ? '继续授权' : '连接'} ? <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>
)} )}
{!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={() => 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> <Button size="sm" variant="ghost" onClick={() => setSettingsDrawer(ch)}><Settings className="h-3 w-3" /></Button>
</>
)}
</div> </div>
</div> </div>
</Card> </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"> <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" /> <Workflow className="h-5 w-5" />
</span> </span>
<h3 className="mt-4 text-base font-semibold text-slate-900">统一处理链路</h3> <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> <p className="mt-1 text-xs leading-5 text-slate-500">渠道消息进入收件箱后,可分配负责人、翻译并跟进。</p>
<div className="mt-4 space-y-2"> <div className="mt-4 space-y-2">
{[ {[
['01', '实时接收与自动同步'], ['01', '接收并同步渠道消息'],
['02', 'AI 翻译、情绪与意图识别'], ['02', '分配负责人并处理消息'],
['03', '客户画像、风险与任务联动'], ['03', '记录客户信息与跟进事项'],
].map(([step, label]) => ( ].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"> <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> <span className="text-[10px] font-bold text-primary">{step}</span>
@@ -421,6 +506,9 @@ export function ChannelsPage() {
if (!open) { if (!open) {
setEmailForm((current) => ({ ...current, password: '' })) setEmailForm((current) => ({ ...current, password: '' }))
resetTelegramWizard() resetTelegramWizard()
setWhatsAppChannelId('')
setWhatsAppPairingCode('')
setWhatsAppQr('')
} }
}} }}
title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`} title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
@@ -438,6 +526,35 @@ export function ChannelsPage() {
queryClient.invalidateQueries() queryClient.invalidateQueries()
return 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) { if (!loginChannelId) {
const phone = telegramAccount.replace(/\s+/g, '') const phone = telegramAccount.replace(/\s+/g, '')
@@ -497,6 +614,10 @@ export function ChannelsPage() {
> >
{wizard.type === 'email' {wizard.type === 'email'
? '验证并连接邮箱' ? '验证并连接邮箱'
: wizard.type === 'whatsapp'
? !whatsappChannelId
? '生成 WhatsApp 配对码'
: '我已在手机确认,检查连接'
: !loginChannelId : !loginChannelId
? '发送 Telegram 验证码' ? '发送 Telegram 验证码'
: authState === 'waiting_password' : authState === 'waiting_password'
@@ -513,7 +634,7 @@ export function ChannelsPage() {
IMAP 收信 + SMTP 发信 IMAP 收信 + SMTP 发信
</div> </div>
<p className="mt-1 text-xs leading-5 text-sky-700"> <p className="mt-1 text-xs leading-5 text-sky-700">
密码在服务端使用 AES-256-GCM 加密保存,不会返回浏览器。Gmail、Outlook 等通常需要应用专用密码。 Gmail、Outlook 等邮箱通常需要使用应用专用密码。
</p> </p>
</div> </div>
<Field label="邮箱服务商"> <Field label="邮箱服务商">
@@ -545,7 +666,7 @@ export function ChannelsPage() {
type="password" type="password"
value={emailForm.password} value={emailForm.password}
onChange={(event) => setEmailForm({ ...emailForm, password: event.target.value })} onChange={(event) => setEmailForm({ ...emailForm, password: event.target.value })}
placeholder="仅提交到本地服务端验证" placeholder="邮箱密码或应用专用密码"
/> />
</Field> </Field>
<div className="grid grid-cols-[1fr_96px] gap-3"> <div className="grid grid-cols-[1fr_96px] gap-3">
@@ -585,6 +706,50 @@ export function ChannelsPage() {
</label> </label>
</div> </div>
</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="space-y-4">
<div className="rounded-xl border border-primary-100 bg-primary-50/70 p-3.5"> <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> <span className="text-sm font-semibold text-primary-900">绑定你自己的 Telegram 工作账号</span>
</div> </div>
<p className="mt-1 text-xs leading-5 text-primary-700"> <p className="mt-1 text-xs leading-5 text-primary-700">
每个企业独立绑定账号,不会共享其他租户的 Telegram 会话或凭据。 可以继续添加其他工作账号,并选择一个默认账号发起新会话。
</p> </p>
</div> </div>
<div aria-label="Telegram 绑定进度" className="grid grid-cols-3 gap-2"> <div aria-label="Telegram 绑定进度" className="grid grid-cols-3 gap-2">
@@ -655,7 +820,7 @@ export function ChannelsPage() {
</Field> </Field>
)} )}
<p className="text-xs leading-5 text-muted-foreground"> <p className="text-xs leading-5 text-muted-foreground">
手机号、验证码和二步密码只通过加密连接发送到本企业的 Telegram Gateway,不写入浏览器本地存储。授权成功后将自动完成 Webhook 注册和首次历史同步。 完成验证后将自动同步历史会话、联系人和媒体消息。
</p> </p>
</div> </div>
)} )}
+5 -5
View File
@@ -68,7 +68,7 @@ export function CustomerDetailPage() {
customerName: c.name, customerName: c.name,
ownerId: user?.id || '', ownerId: user?.id || '',
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
note: `由客户画像的 AI 建议创建:${title}`, note: `由客户画像建议创建:${title}`,
}) })
await queryClient.invalidateQueries({ queryKey: ['tasks'] }) await queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整') toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整')
@@ -115,7 +115,7 @@ export function CustomerDetailPage() {
</div> </div>
) : ( ) : (
<p className="text-xs leading-5 text-slate-400"> <p className="text-xs leading-5 text-slate-400">
暂无标签,AI 将在收到有效客户消息后自动补全。 暂无标签,收到更多客户消息后将自动补充。
</p> </p>
)} )}
</div> </div>
@@ -159,7 +159,7 @@ export function CustomerDetailPage() {
<CustomerProfileRadar customer={c} className="xl:col-span-5" /> <CustomerProfileRadar customer={c} className="xl:col-span-5" />
<Card className="xl:col-span-7"> <Card className="xl:col-span-7">
<CardHeader title="AI 客户洞察" description="结合完整会话、购买信号和风险变化生成" /> <CardHeader title="客户洞察" description="参考已同步的会话、购买信号和风险变化" />
<div className="p-5"> <div className="p-5">
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{[ {[
@@ -174,7 +174,7 @@ export function CustomerDetailPage() {
))} ))}
</div> </div>
<p className="mt-5 text-sm leading-7 text-slate-600"> <p className="mt-5 text-sm leading-7 text-slate-600">
{c.aiProfile.summary || 'AI 正在根据 Telegram 会话补全客户画像。'} {c.aiProfile.summary || '收到更多客户消息后将补充客户画像。'}
</p> </p>
{c.aiProfile.evidence?.length ? ( {c.aiProfile.evidence?.length ? (
<div className="mt-5 border-t border-slate-100 pt-4"> <div className="mt-5 border-t border-slate-100 pt-4">
@@ -193,7 +193,7 @@ export function CustomerDetailPage() {
</Card> </Card>
<Card className="xl:col-span-8"> <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"> <div className="grid grid-cols-2 gap-px overflow-hidden rounded-b-2xl bg-slate-100 md:grid-cols-4">
{Object.entries({ {Object.entries({
询价数量: c.aiProfile.inquiryQuantity, 询价数量: c.aiProfile.inquiryQuantity,
+1 -1
View File
@@ -178,7 +178,7 @@ export function CustomersPage() {
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<PageHeader <PageHeader
title="客户管理" title="客户管理"
description={`共 ${total} 个客户 · 统一管理与跟进`} description={`共 ${total} 个客户`}
actions={ actions={
<> <>
<PermissionGate permission="customer:export"> <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 className="mb-3 flex items-end justify-between">
<div> <div>
<p className="eyebrow">Today</p> <p className="eyebrow">工作台</p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">今日经营重点</h2> <h2 className="mt-1 text-lg font-semibold tracking-tight">今日重点</h2>
</div> </div>
<button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button> <button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button>
</div> </div>
@@ -252,7 +252,7 @@ export function DashboardPage() {
<div className="border-t border-border p-3"> <div className="border-t border-border p-3">
<Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}> <Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}>
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
进入统一消息中心 查看全部消息
</Button> </Button>
</div> </div>
</Card> </Card>
+2 -2
View File
@@ -7,8 +7,8 @@ const HELP_ITEMS = [
{ icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' }, { icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' },
{ icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' }, { icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' },
{ icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' }, { icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' },
{ icon: Languages, title: 'AI 上下文翻译', desc: '结合当前会话上下文进行跨境商务翻译', path: '/translate' }, { icon: Languages, title: '消息翻译', desc: '翻译当前消息并保留双语对照', path: '/translate' },
{ icon: UsersRound, title: '邀请团队成员', desc: '通过已配置的企业 SMTP 发送真实邀请邮件', path: '/team' }, { icon: UsersRound, title: '邀请团队成员', desc: '向同事发送团队邀请邮件', path: '/team' },
{ icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' }, { icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' },
] ]
+433 -65
View File
@@ -22,15 +22,21 @@ import {
getConversations, getConversations,
getConversation, getConversation,
getMessages, getMessages,
normalizeMessage,
sendMessage, sendMessage,
sendMedia, sendMedia,
sendAlbum, sendAlbum,
updateConversation, updateConversation,
updateMessage, updateMessage,
markConversationRead, markConversationRead,
setConversationPresence,
openTelegramConversation, openTelegramConversation,
runTelegramConversationAction, openWhatsAppConversation,
type TelegramConversationAction, runConversationAction,
runWhatsAppMessageAction,
getWhatsAppGroup,
runWhatsAppGroupAction,
type ConversationAction,
type ConversationListItem, type ConversationListItem,
} from '@/services/api/conversations' } from '@/services/api/conversations'
import { updateCustomer } from '@/services/api/customers' import { updateCustomer } from '@/services/api/customers'
@@ -127,6 +133,8 @@ export function InboxPage() {
}) })
const [draftText, setDraftText] = useState('') const [draftText, setDraftText] = useState('')
const [draftTranslationCn, setDraftTranslationCn] = 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 [replyingTo, setReplyingTo] = useState<{ id: string; text: string } | null>(null)
const [noteTarget, setNoteTarget] = useState<Message | null>(null) const [noteTarget, setNoteTarget] = useState<Message | null>(null)
const [noteDraft, setNoteDraft] = useState('') const [noteDraft, setNoteDraft] = useState('')
@@ -136,14 +144,17 @@ export function InboxPage() {
customer: Customer customer: Customer
} | null>(null) } | null>(null)
const [addTelegramOpen, setAddTelegramOpen] = useState(false) const [addTelegramOpen, setAddTelegramOpen] = useState(false)
const [addConversationChannel, setAddConversationChannel] = useState<'telegram' | 'whatsapp'>('telegram')
const [addTelegramType, setAddTelegramType] = useState<'user' | 'chat' | 'invite'>('user') const [addTelegramType, setAddTelegramType] = useState<'user' | 'chat' | 'invite'>('user')
const [addTelegramIdentifier, setAddTelegramIdentifier] = useState('') const [addTelegramIdentifier, setAddTelegramIdentifier] = useState('')
const [addGroupParticipants, setAddGroupParticipants] = useState('')
const [addTelegramLoading, setAddTelegramLoading] = useState(false) const [addTelegramLoading, setAddTelegramLoading] = useState(false)
const [conversationActionTarget, setConversationActionTarget] = useState<{ const [conversationActionTarget, setConversationActionTarget] = useState<{
item: ConversationListItem item: ConversationListItem
action: 'clear' | 'delete' | 'leave' action: 'clear' | 'delete' | 'leave'
} | null>(null) } | null>(null)
const [conversationActionLoading, setConversationActionLoading] = useState(false) const [conversationActionLoading, setConversationActionLoading] = useState(false)
const [whatsappGroupOpen, setWhatsAppGroupOpen] = useState(false)
const messageListRef = useRef<HTMLDivElement>(null) const messageListRef = useRef<HTMLDivElement>(null)
const messageContentRef = useRef<HTMLDivElement>(null) const messageContentRef = useRef<HTMLDivElement>(null)
@@ -305,14 +316,15 @@ export function InboxPage() {
}) })
} }
if (event.conversationId && event.message) { if (event.conversationId && event.message) {
const normalizedMessage = normalizeMessage(event.message)
queryClient.setQueryData<Message[]>(['messages', event.conversationId], (current = []) => { queryClient.setQueryData<Message[]>(['messages', event.conversationId], (current = []) => {
const withoutOld = event.oldMessageId const withoutOld = event.oldMessageId
? current.filter((item) => String(item.id) !== String(event.oldMessageId)) ? current.filter((item) => String(item.id) !== String(event.oldMessageId))
: current : current
const index = withoutOld.findIndex((item) => String(item.id) === String(event.message!.id)) const index = withoutOld.findIndex((item) => String(item.id) === String(normalizedMessage.id))
if (index < 0) return [...withoutOld, event.message!] if (index < 0) return [...withoutOld, normalizedMessage]
const next = [...withoutOld] const next = [...withoutOld]
next[index] = { ...next[index], ...event.message! } next[index] = normalizeMessage({ ...next[index], ...normalizedMessage })
return next return next
}) })
queryClient.setQueriesData<{ list: Array<{ id: string; lastMessage?: Message }>; total: number }>( 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 || '') String(item?.lastMessage?.id || '') === String(event.message?.id || '')
if (!isNewMessage && !isCurrentLastMessage) return typed if (!isNewMessage && !isCurrentLastMessage) return typed
return updateRealtimeConversationList(typed, event.conversationId!, { return updateRealtimeConversationList(typed, event.conversationId!, {
lastMessage: event.message, lastMessage: normalizedMessage,
}) })
}, },
) )
} }
if (event.conversationId && event.conversation) { if (event.conversationId && event.conversation) {
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => ({ 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, conversation: event.conversation,
customer: event.customer || current?.customer, customer,
})) }
})
queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>( queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>(
{ queryKey: ['conversations'] }, { queryKey: ['conversations'] },
(current) => updateRealtimeConversationList(current, event.conversationId!, { (current) => updateRealtimeConversationList(current, event.conversationId!, {
@@ -436,9 +455,30 @@ export function InboxPage() {
autoTranslate, autoTranslate,
translationCn: draftTranslationCn || undefined, translationCn: draftTranslationCn || undefined,
}) })
if (convDetail?.conversation.channel === 'whatsapp') {
void setConversationPresence(convId, 'paused').catch(() => undefined)
}
setDraftTranslationCn('') 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') => { const handleMedia = async (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => {
if (!convId) return if (!convId) return
const encoded = await Promise.all(files.map(async (file) => { const encoded = await Promise.all(files.map(async (file) => {
@@ -467,14 +507,15 @@ export function InboxPage() {
} }
} }
const executeTelegramAction = async ( const executeConversationAction = async (
action: TelegramConversationAction, action: ConversationAction,
item?: ConversationListItem, item?: ConversationListItem,
) => { ) => {
const id = item?.id || convId const id = item?.id || convId
if (!id) return if (!id) return
const provider = (item?.channel || convDetail?.conversation.channel) === 'whatsapp' ? 'WhatsApp' : 'Telegram'
try { try {
const result = await runTelegramConversationAction(id, action) const result = await runConversationAction(id, action)
if ('deleted' in result && result.deleted) { if ('deleted' in result && result.deleted) {
queryClient.removeQueries({ queryKey: ['messages', id] }) queryClient.removeQueries({ queryKey: ['messages', id] })
queryClient.removeQueries({ queryKey: ['conversation', id] }) queryClient.removeQueries({ queryKey: ['conversation', id] })
@@ -482,9 +523,9 @@ export function InboxPage() {
} else { } else {
await refreshConversationState(id) await refreshConversationState(id)
} }
const labels: Partial<Record<TelegramConversationAction, string>> = { const labels: Partial<Record<ConversationAction, string>> = {
clear: '消息记录已清空', clear: '消息记录已清空',
delete: '会话已从 Telegram 列表删除', delete: `会话已从 ${provider} 列表删除`,
pin: '会话已置顶', pin: '会话已置顶',
unpin: '已取消置顶', unpin: '已取消置顶',
archive: '会话已归档', archive: '会话已归档',
@@ -493,10 +534,10 @@ export function InboxPage() {
unmute: '会话已恢复通知', unmute: '会话已恢复通知',
'mark-unread': '会话已标记为未读', 'mark-unread': '会话已标记为未读',
leave: '已退出群组或频道', leave: '已退出群组或频道',
block: '已拉黑该 Telegram 用户', block: `已拉黑该 ${provider} 用户`,
unblock: '已解除拉黑', unblock: '已解除拉黑',
} }
toast.success(labels[action] || 'Telegram 操作已完成') toast.success(labels[action] || `${provider} 操作已完成`)
} catch (error) { } catch (error) {
toast.error(errorMessage(error)) toast.error(errorMessage(error))
throw error throw error
@@ -507,7 +548,7 @@ export function InboxPage() {
if (!conversationActionTarget) return if (!conversationActionTarget) return
setConversationActionLoading(true) setConversationActionLoading(true)
try { try {
await executeTelegramAction(conversationActionTarget.action, conversationActionTarget.item) await executeConversationAction(conversationActionTarget.action, conversationActionTarget.item)
setConversationActionTarget(null) setConversationActionTarget(null)
} finally { } finally {
setConversationActionLoading(false) setConversationActionLoading(false)
@@ -517,20 +558,30 @@ export function InboxPage() {
const addTelegramConversation = async () => { const addTelegramConversation = async () => {
const identifier = addTelegramIdentifier.trim() const identifier = addTelegramIdentifier.trim()
if (!identifier) { if (!identifier) {
toast.error('请输入 Telegram 标识') toast.error(`请输入 ${addConversationChannel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 标识`)
return return
} }
setAddTelegramLoading(true) setAddTelegramLoading(true)
try { try {
const value = await openTelegramConversation({ 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, identifier,
targetType: addTelegramType, targetType: addTelegramType,
}) })
await refreshConversationState(value.conversation.id) await refreshConversationState(value.conversation.id)
setAddTelegramOpen(false) setAddTelegramOpen(false)
setAddTelegramIdentifier('') setAddTelegramIdentifier('')
setAddGroupParticipants('')
setConv(value.conversation.id) setConv(value.conversation.id)
toast.success('Telegram 会话已添加', value.customer.name) toast.success(`${addConversationChannel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 会话已添加`, value.customer.name)
} catch (error) { } catch (error) {
toast.error(errorMessage(error)) toast.error(errorMessage(error))
} finally { } finally {
@@ -542,7 +593,39 @@ export function InboxPage() {
quote: (msg: Message) => { quote: (msg: Message) => {
if (!convId) return if (!convId) return
setReplyingTo({ id: msg.id, text: msg.originalText || msg.attachmentName || `[${msg.type}]` }) 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) => { markImportant: async (msg: Message) => {
if (!convId) return if (!convId) return
@@ -564,8 +647,9 @@ export function InboxPage() {
try { try {
const { createTask } = await import('@/services/api/crm') const { createTask } = await import('@/services/api/crm')
const dueAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() const dueAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()
const sourceText = String(msg.originalText || '')
await createTask({ await createTask({
title: `跟进:${msg.originalText.slice(0, 36) || msg.attachmentName || '客户消息'}`, title: `跟进:${sourceText.slice(0, 36) || msg.attachmentName || '客户消息'}`,
type: 'revisit', type: 'revisit',
priority: convDetail.customer.riskLevel === 'urgent' || convDetail.customer.riskLevel === 'high' ? 'high' : 'medium', priority: convDetail.customer.riskLevel === 'urgent' || convDetail.customer.riskLevel === 'high' ? 'high' : 'medium',
status: 'pending', status: 'pending',
@@ -574,20 +658,21 @@ export function InboxPage() {
conversationId: convId, conversationId: convId,
ownerId: user?.id || '', ownerId: user?.id || '',
dueAt, dueAt,
note: `来源消息:${msg.originalText || msg.attachmentName || msg.type}`, note: `来源消息:${sourceText || msg.attachmentName || msg.type}`,
} as any) } as any)
queryClient.invalidateQueries({ queryKey: ['tasks'] }) queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('已创建真实跟进待办', '默认明天到期,可在商机与跟进中调整') toast.success('已创建跟进待办', '默认明天到期,可在商机与跟进中调整')
} catch (error) { } catch (error) {
toast.error(errorMessage(error)) toast.error(errorMessage(error))
} }
}, },
knowledge: (msg: Message) => { knowledge: (msg: Message) => {
const sourceText = String(msg.originalText || '')
import('@/services/api/knowledge') import('@/services/api/knowledge')
.then(({ createKnowledge }) => .then(({ createKnowledge }) =>
createKnowledge({ createKnowledge({
title: `会话消息提取:${msg.originalText.slice(0, 30)}`, title: `会话消息提取:${sourceText.slice(0, 30) || msg.attachmentName || '客户消息'}`,
content: msg.originalText, content: sourceText || msg.attachmentName || `[${msg.type}]`,
source: 'reply', source: 'reply',
}), }),
) )
@@ -615,7 +700,7 @@ export function InboxPage() {
open={noteTarget !== null} open={noteTarget !== null}
onOpenChange={(open) => { if (!open) { setNoteTarget(null); setNoteDraft('') } }} onOpenChange={(open) => { if (!open) { setNoteTarget(null); setNoteDraft('') } }}
title="内部备注" title="内部备注"
description="仅团队内部可见,不会发送给 Telegram 联系人。" description="仅团队内部可见,不会发送给外部联系人。"
footer={ footer={
<> <>
<Button variant="ghost" onClick={() => setNoteTarget(null)}>取消</Button> <Button variant="ghost" onClick={() => setNoteTarget(null)}>取消</Button>
@@ -636,20 +721,33 @@ export function InboxPage() {
</Dialog> </Dialog>
) )
const whatsappGroupDialog = convId ? (
<WhatsAppGroupDialog
conversationId={convId}
open={whatsappGroupOpen}
onOpenChange={setWhatsAppGroupOpen}
/>
) : null
const telegramDialogs = ( const telegramDialogs = (
<> <>
<Dialog <Dialog
open={addTelegramOpen} open={addTelegramOpen}
onOpenChange={setAddTelegramOpen} onOpenChange={setAddTelegramOpen}
title="添加 Telegram 会话" title="添加联系人或群组"
description="通过真实 Telegram 标识创建或加入会话,添加后立即同步最近消息。" description="通过渠道账号创建或加入会话,添加后立即同步最近消息。"
footer={( footer={(
<> <>
<Button variant="ghost" onClick={() => setAddTelegramOpen(false)}>取消</Button> <Button variant="ghost" onClick={() => setAddTelegramOpen(false)}>取消</Button>
<Button <Button
onClick={addTelegramConversation} onClick={addTelegramConversation}
loading={addTelegramLoading} loading={addTelegramLoading}
disabled={!addTelegramIdentifier.trim()} disabled={
!addTelegramIdentifier.trim()
|| (addConversationChannel === 'whatsapp'
&& addTelegramType === 'chat'
&& !addGroupParticipants.replace(/\D/g, ''))
}
> >
添加并打开 添加并打开
</Button> </Button>
@@ -657,6 +755,30 @@ export function InboxPage() {
)} )}
> >
<div className="space-y-4"> <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"> <div className="grid grid-cols-3 gap-2 rounded-xl bg-slate-100 p-1">
{[ {[
{ value: 'user' as const, label: '用户' }, { value: 'user' as const, label: '用户' },
@@ -679,15 +801,19 @@ export function InboxPage() {
))} ))}
</div> </div>
<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' {addTelegramType === 'user'
? '用户名、手机号或用户 ID' ? addConversationChannel === 'whatsapp'
? 'WhatsApp 手机号'
: '用户名、手机号或用户 ID'
: addTelegramType === 'chat' : addTelegramType === 'chat'
? '群组/频道用户名或 Chat ID' ? addConversationChannel === 'whatsapp'
: 'Telegram 邀请链接'} ? '新群组名称'
: '群组/频道用户名或 Chat ID'
: `${addConversationChannel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 邀请链接`}
</label> </label>
<input <input
id="telegram-identifier" id="channel-identifier"
autoFocus autoFocus
value={addTelegramIdentifier} value={addTelegramIdentifier}
onChange={(event) => setAddTelegramIdentifier(event.target.value)} onChange={(event) => setAddTelegramIdentifier(event.target.value)}
@@ -696,15 +822,33 @@ export function InboxPage() {
}} }}
placeholder={ placeholder={
addTelegramType === 'user' addTelegramType === 'user'
? '@username、+1 234… 或 123456789' ? addConversationChannel === 'whatsapp'
? '+1 929 738 5556'
: '@username、+1 234… 或 123456789'
: addTelegramType === 'chat' : addTelegramType === 'chat'
? '@groupname 或 -1001234567890' ? addConversationChannel === 'whatsapp'
? '例如:北美客户服务群'
: '@groupname 或 -1001234567890'
: addConversationChannel === 'whatsapp'
? 'https://chat.whatsapp.com/xxxxxxxx'
: 'https://t.me/+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" 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"> <p className="mt-2 text-xs leading-5 text-slate-500">
用户 ID 与 Chat ID 含义不同,请先选择正确类型。邀请链接加入群组可能需要管理员审批。 {addConversationChannel === 'whatsapp'
? addTelegramType === 'chat'
? '创建群组至少需要一名成员,手机号必须包含国家代码。'
: '手机号必须包含国家代码;邀请链接会让当前 WhatsApp 账号加入群组。'
: '用户 ID 与 Chat ID 含义不同,请先选择正确类型。邀请链接加入群组可能需要管理员审批。'}
</p> </p>
</div> </div>
</div> </div>
@@ -715,7 +859,7 @@ export function InboxPage() {
onOpenChange={(open) => { if (!open && !conversationActionLoading) setConversationActionTarget(null) }} onOpenChange={(open) => { if (!open && !conversationActionLoading) setConversationActionTarget(null) }}
title={ title={
conversationActionTarget?.action === 'delete' conversationActionTarget?.action === 'delete'
? '删除 Telegram 会话' ? `删除 ${conversationActionTarget?.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 会话`
: conversationActionTarget?.action === 'leave' : 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"> <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' {conversationActionTarget?.action === 'delete'
? String(conversationActionTarget.item.telegramChatType || '').toLowerCase() === 'channel' ? conversationActionTarget.item.channel === 'telegram'
&& String(conversationActionTarget.item.telegramChatType || '').toLowerCase() === 'channel'
? '这会先退出 Telegram 频道,再从当前账号和灵犀跨境中移除会话;不会全局删除频道帖子,也不会删除客户档案。' ? '这会先退出 Telegram 频道,再从当前账号和灵犀跨境中移除会话;不会全局删除频道帖子,也不会删除客户档案。'
: '这会清除当前 Telegram 账号中的聊天历史并从会话列表移除,但不会删除客户档案,也不会撤回客户已收到的消息。' : `这会清除当前 ${conversationActionTarget.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 账号中的聊天历史并从会话列表移除,但不会删除客户档案,也不会撤回客户已收到的消息。`
: conversationActionTarget?.action === 'leave' : conversationActionTarget?.action === 'leave'
? '退出后将不能继续收发该群组或频道消息,重新加入可能需要新的邀请链接或管理员审批。' ? '退出后将不能继续收发该群组或频道消息,重新加入可能需要新的邀请链接或管理员审批。'
: '这会清除当前 Telegram 账号和灵犀跨境中的聊天记录,但不会撤回客户已收到的消息。'} : `这会清除当前 ${conversationActionTarget?.item.channel === 'whatsapp' ? 'WhatsApp' : 'Telegram'} 账号和灵犀跨境中的聊天记录,但不会撤回客户已收到的消息。`}
</div> </div>
</Dialog> </Dialog>
</> </>
@@ -808,8 +953,11 @@ export function InboxPage() {
onConvertToTask={() => handleAction.task(msg)} onConvertToTask={() => handleAction.task(msg)}
onAddToKnowledge={() => handleAction.knowledge(msg)} onAddToKnowledge={() => handleAction.knowledge(msg)}
onAddNote={() => handleAction.addNote(msg)} onAddNote={() => handleAction.addNote(msg)}
customerName={convDetail?.customer.name} onReact={(emoji) => void handleAction.react(msg, emoji)}
customerAvatar={convDetail?.customer.avatarUrl} onDelete={() => void handleAction.deleteMessage(msg)}
customerName={convDetail?.customer?.name}
customerAvatar={convDetail?.customer?.avatarUrl}
channel={convDetail?.conversation.channel}
/> />
</div> </div>
) )
@@ -821,6 +969,7 @@ export function InboxPage() {
return ( return (
<div className="flex h-[calc(100vh-64px)] min-h-0 flex-col overflow-hidden bg-white"> <div className="flex h-[calc(100vh-64px)] min-h-0 flex-col overflow-hidden bg-white">
{noteDialog} {noteDialog}
{whatsappGroupDialog}
{telegramDialogs} {telegramDialogs}
{incomingNotice && ( {incomingNotice && (
<IncomingMessageNotice <IncomingMessageNotice
@@ -865,14 +1014,15 @@ export function InboxPage() {
if (['clear', 'delete', 'leave'].includes(action)) { if (['clear', 'delete', 'leave'].includes(action)) {
setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' }) setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' })
} else { } else {
void executeTelegramAction(action, item) void executeConversationAction(action, item)
} }
}} }}
onGroupManage={() => setWhatsAppGroupOpen(true)}
onBack={() => setMobilePanel('list')} onBack={() => setMobilePanel('list')}
onInsight={() => setMobilePanel('insight')} onInsight={() => setMobilePanel('insight')}
showInsightBtn showInsightBtn
/> />
{convDetail && ( {convDetail?.customer && convDetail?.conversation && (
<ConversationInsightBar <ConversationInsightBar
customer={convDetail.customer} customer={convDetail.customer}
conversation={convDetail.conversation} conversation={convDetail.conversation}
@@ -901,7 +1051,7 @@ export function InboxPage() {
) : ( ) : (
<div className="flex justify-end border-t border-border px-3 py-1.5"> <div className="flex justify-end border-t border-border px-3 py-1.5">
<Button variant="ghost" size="sm" onClick={() => setShowAiReplies(true)}> <Button variant="ghost" size="sm" onClick={() => setShowAiReplies(true)}>
AI 智能回复 回复建议
</Button> </Button>
</div> </div>
)} )}
@@ -910,7 +1060,7 @@ export function InboxPage() {
onMedia={handleMedia} onMedia={handleMedia}
value={draftText} value={draftText}
onChange={(text) => { onChange={(text) => {
setDraftText(text) handleDraftChange(text)
setDraftTranslationCn('') setDraftTranslationCn('')
}} }}
disabled={!convId} disabled={!convId}
@@ -920,7 +1070,7 @@ export function InboxPage() {
/> />
</div> </div>
)} )}
{mobilePanel === 'insight' && convDetail && ( {mobilePanel === 'insight' && convDetail?.customer && convDetail?.conversation && (
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2.5"> <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')}> <Button variant="ghost" size="icon-sm" title="返回会话" aria-label="返回会话" onClick={() => setMobilePanel('chat')}>
@@ -939,6 +1089,7 @@ export function InboxPage() {
return ( return (
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-white"> <div className="flex h-[calc(100vh-64px)] overflow-hidden bg-white">
{noteDialog} {noteDialog}
{whatsappGroupDialog}
{telegramDialogs} {telegramDialogs}
{incomingNotice && ( {incomingNotice && (
<IncomingMessageNotice <IncomingMessageNotice
@@ -993,13 +1144,14 @@ export function InboxPage() {
if (['clear', 'delete', 'leave'].includes(action)) { if (['clear', 'delete', 'leave'].includes(action)) {
setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' }) setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' })
} else { } else {
void executeTelegramAction(action, item) void executeConversationAction(action, item)
} }
}} }}
onGroupManage={() => setWhatsAppGroupOpen(true)}
onInsight={() => setShowInsight((v) => !v)} onInsight={() => setShowInsight((v) => !v)}
showInsightBtn showInsightBtn
/> />
{convDetail && ( {convDetail?.customer && convDetail?.conversation && (
<ConversationInsightBar <ConversationInsightBar
customer={convDetail.customer} customer={convDetail.customer}
conversation={convDetail.conversation} conversation={convDetail.conversation}
@@ -1032,7 +1184,7 @@ export function InboxPage() {
) : ( ) : (
<div className="flex justify-end border-t border-border px-3 py-1.5"> <div className="flex justify-end border-t border-border px-3 py-1.5">
<Button variant="ghost" size="sm" onClick={() => setShowAiReplies(true)}> <Button variant="ghost" size="sm" onClick={() => setShowAiReplies(true)}>
AI 智能回复 回复建议
</Button> </Button>
</div> </div>
)} )}
@@ -1041,7 +1193,7 @@ export function InboxPage() {
onMedia={handleMedia} onMedia={handleMedia}
value={draftText} value={draftText}
onChange={(text) => { onChange={(text) => {
setDraftText(text) handleDraftChange(text)
setDraftTranslationCn('') setDraftTranslationCn('')
}} }}
disabled={!convId} disabled={!convId}
@@ -1053,14 +1205,14 @@ export function InboxPage() {
) : ( ) : (
<EmptyState <EmptyState
title="选择会话开始沟通" title="选择会话开始沟通"
description="左侧选择一条会话,查看消息记录、AI 分析与客户洞察" description="从左侧选择会话,查看消息记录、客户洞察和回复建议"
className="flex-1" className="flex-1"
/> />
)} )}
</div> </div>
{/* 右栏:客户洞察 340px */} {/* 右栏:客户洞察 340px */}
{showInsight && convDetail && ( {showInsight && convDetail?.customer && convDetail?.conversation && (
<div className="w-[380px] shrink-0 border-s border-slate-200/80 bg-slate-50/40"> <div className="w-[380px] shrink-0 border-s border-slate-200/80 bg-slate-50/40">
<InsightPanel customer={convDetail.customer} conversation={convDetail.conversation} /> <InsightPanel customer={convDetail.customer} conversation={convDetail.conversation} />
</div> </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({ function ChatHeader({
convId, convId,
@@ -1079,6 +1398,7 @@ function ChatHeader({
wallpaper, wallpaper,
onWallpaperChange, onWallpaperChange,
onTelegramAction, onTelegramAction,
onGroupManage,
onBack, onBack,
onInsight, onInsight,
showInsightBtn, showInsightBtn,
@@ -1090,7 +1410,8 @@ function ChatHeader({
onViewModeChange: (v: ViewMode) => void onViewModeChange: (v: ViewMode) => void
wallpaper: ChatWallpaper wallpaper: ChatWallpaper
onWallpaperChange: (wallpaper: ChatWallpaper) => void onWallpaperChange: (wallpaper: ChatWallpaper) => void
onTelegramAction: (action: TelegramConversationAction) => void onTelegramAction: (action: ConversationAction) => void
onGroupManage?: () => void
onBack?: () => void onBack?: () => void
onInsight?: () => void onInsight?: () => void
showInsightBtn?: boolean showInsightBtn?: boolean
@@ -1098,11 +1419,29 @@ function ChatHeader({
if (!convId) return null if (!convId) return null
const c = detail?.customer const c = detail?.customer
const conv = detail?.conversation 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 ( 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"> <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 && ( {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" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
)} )}
@@ -1112,7 +1451,16 @@ function ChatHeader({
<> <>
<div className="relative"> <div className="relative">
<Avatar name={c?.name ?? '?'} src={c?.avatarUrl} size="md" /> <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>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1123,6 +1471,13 @@ function ChatHeader({
<p className="truncate text-[11px] text-slate-400"> <p className="truncate text-[11px] text-slate-400">
{conv?.channel === 'email' {conv?.channel === 'email'
? conv.emailSubject || c?.email || 'Email 会话' ? conv.emailSubject || c?.email || 'Email 会话'
: conv?.channel === 'whatsapp'
? presenceLabel
|| (c?.phone
? `${c.phone} · WhatsApp`
: chatType === 'group'
? 'WhatsApp 群组'
: 'WhatsApp 会话')
: c?.telegramUsername : c?.telegramUsername
? `@${c.telegramUsername} · 在线` ? `@${c.telegramUsername} · 在线`
: 'Telegram 会话'} : 'Telegram 会话'}
@@ -1157,14 +1512,27 @@ function ChatHeader({
onSelect: () => onWallpaperChange(item.value), onSelect: () => onWallpaperChange(item.value),
}))} }))}
/> />
{conv?.channel === 'telegram' && ( {['telegram', 'whatsapp'].includes(conv?.channel) && (
<Dropdown <Dropdown
trigger={( 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" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
)} )}
items={[ items={[
...(conv.channel === 'whatsapp' && chatType === 'group' && onGroupManage
? [{
key: 'group-manage',
label: '群组管理',
icon: UserRound,
onSelect: onGroupManage,
}, 'separator' as const]
: []),
{ {
key: conv.isPinned ? 'unpin' : 'pin', key: conv.isPinned ? 'unpin' : 'pin',
label: conv.isPinned ? '取消置顶' : '置顶会话', label: conv.isPinned ? '取消置顶' : '置顶会话',
@@ -1193,7 +1561,7 @@ function ChatHeader({
key: conv.isBlocked ? 'unblock' : 'block', key: conv.isBlocked ? 'unblock' : 'block',
label: conv.isBlocked ? '解除拉黑' : '拉黑用户', label: conv.isBlocked ? '解除拉黑' : '拉黑用户',
icon: Ban, icon: Ban,
disabled: !c?.telegramUserId, disabled: conv.channel === 'telegram' && !c?.telegramUserId,
onSelect: () => onTelegramAction(conv.isBlocked ? 'unblock' : 'block'), onSelect: () => onTelegramAction(conv.isBlocked ? 'unblock' : 'block'),
}, },
'separator', 'separator',
@@ -1202,16 +1570,16 @@ function ChatHeader({
key: 'clear', key: 'clear',
label: '清空消息', label: '清空消息',
icon: Eraser, icon: Eraser,
onSelect: () => onTelegramAction('clear' as TelegramConversationAction), onSelect: () => onTelegramAction('clear' as ConversationAction),
}] }]
: []), : []),
...(String(conv.telegramChatType || c?.telegramChatType || '').toLowerCase() !== 'private' ...(chatType !== 'private'
? [{ ? [{
key: 'leave', key: 'leave',
label: '退出群组/频道', label: '退出群组/频道',
icon: LogOut, icon: LogOut,
danger: true, 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"> <div className="p-4 md:p-6">
<PageHeader <PageHeader
title="企业知识库" title="企业知识库"
description={`${list.length} 条知识 · AI 回复与翻译的精准来源`} description={`${list.length} 条知识 · 用于回复建议与翻译参考`}
actions={ actions={
<> <>
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button> <Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
@@ -129,7 +129,7 @@ export function KnowledgePage() {
{tab === 'search' && ( {tab === 'search' && (
<Card className="p-4"> <Card className="p-4">
<div className="flex gap-2"> <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 () => { <Button loading={searchLoading} onClick={async () => {
if (!searchQ.trim()) return if (!searchQ.trim()) return
setSearchLoading(true) setSearchLoading(true)
@@ -142,7 +142,7 @@ export function KnowledgePage() {
{searchResult && ( {searchResult && (
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<Card className="p-3 bg-primary-50/30"> <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> <p className="text-sm">{searchResult.answer}</p>
</Card> </Card>
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p> <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 || []))} onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
className="block w-full rounded-md border border-input p-2 text-sm" 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> </Dialog>
</div> </div>
) )
+2 -2
View File
@@ -55,7 +55,7 @@ export function RisksPage() {
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<PageHeader <PageHeader
title="风险预警中心" title="风险预警中心"
description="智能识别交易风险、客户流失信号与敏感表达,帮助团队主动应对" description="查看交易风险、客户流失信号和敏感表达"
actions={ actions={
<PermissionGate permission="settings:manage"> <PermissionGate permission="settings:manage">
<Button size="sm" variant="outline" onClick={() => navigate('/settings')}><Settings className="h-4 w-4" />风险识别设置</Button> <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> <span className="text-xs text-muted-foreground">{formatRelative(detail.createdAt)}</span>
</div> </div>
<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> <p className="text-sm text-muted-foreground leading-relaxed">{detail.aiExplanation}</p>
</div> </div>
<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" 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" 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> <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 () => { <Button onClick={async () => {
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致') if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
try { try {
@@ -148,7 +148,7 @@ export function SettingsPage() {
<Card> <Card>
<CardHeader title="API 与 Webhook" /> <CardHeader title="API 与 Webhook" />
<div className="space-y-3 p-4"> <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"> <div className="flex gap-2">
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" /> <Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
<Button variant="outline" onClick={async () => { <Button variant="outline" onClick={async () => {
+2 -2
View File
@@ -66,8 +66,8 @@ export function TranslatePage() {
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<PageHeader <PageHeader
title="AI 翻译中心" title="翻译中心"
description={`跨境商务场景专用翻译引擎,支持全球主流贸易国家和地区语言(可配置 ${supportedLanguageCount} 种)。`} description={`支持 ${supportedLanguageCount} 种已配置语言,可用于消息翻译与回复。`}
actions={<LiveDataBadge />} actions={<LiveDataBadge />}
/> />
+102 -2
View File
@@ -52,8 +52,30 @@ export function getTelegramLoginState(channelId: string) {
) )
} }
export function syncTelegramChannel(channelId: string) { export function startWhatsAppLogin(phone: string, name?: string) {
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`) 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 */ /** POST /api/channels/test */
@@ -75,3 +97,81 @@ export function deleteChannel(id: string) {
export function getChannelLogs(id: string) { export function getChannelLogs(id: string) {
return http.get<ChannelSyncLog[]>(`/api/channels/${id}/logs`) 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 注入,避免循环依赖 */ /** 由 auth store 注入,避免循环依赖 */
let tokenGetter: () => string | null = () => null let tokenGetter: () => string | null = () => null
let unauthorizedHandler: (() => void) | null = null
let handlingUnauthorized = false
export function setTokenGetter(fn: () => string | null) { export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn tokenGetter = fn
} }
export function setUnauthorizedHandler(fn: () => void) {
unauthorizedHandler = fn
}
export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> { export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
const method = options.method ?? 'GET' const method = options.method ?? 'GET'
@@ -41,6 +48,11 @@ export async function api<T = unknown>(path: string, options: RequestOptions = {
}) })
if (res.status === 401) { if (res.status === 401) {
if (token && unauthorizedHandler && !handlingUnauthorized) {
handlingUnauthorized = true
unauthorizedHandler()
window.setTimeout(() => { handlingUnauthorized = false }, 1_000)
}
throw new ApiError(401, '登录状态已失效,请重新登录') throw new ApiError(401, '登录状态已失效,请重新登录')
} }
if (!res.ok) { if (!res.ok) {
+124 -9
View File
@@ -10,6 +10,15 @@ export interface ConversationQuery {
userId?: string 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 */ /** GET /api/conversations */
export function getConversations(query: ConversationQuery = {}) { export function getConversations(query: ConversationQuery = {}) {
return http.get<{ list: ConversationListItem[]; total: number }>('/api/conversations', { ...query }) return http.get<{ list: ConversationListItem[]; total: number }>('/api/conversations', { ...query })
@@ -21,8 +30,26 @@ export function getConversation(id: string) {
} }
/** GET /api/conversations/:id/messages */ /** GET /api/conversations/:id/messages */
export function getMessages(id: string) { export async function getMessages(id: string) {
return http.get<Message[]>(`/api/conversations/${id}/messages`) 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 }) { 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 }) 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) { export function getMessageAttachment(id: string, messageId: string, attachmentId?: string) {
return http.get<{ dataUrl: string; mimeType: string; name: string }>( return http.get<{ dataUrl: string; mimeType: string; name: string }>(
`/api/conversations/${id}/messages/${messageId}/attachment`, `/api/conversations/${id}/messages/${messageId}/attachment`,
@@ -41,15 +75,40 @@ export function getMessageAttachment(id: string, messageId: string, attachmentId
} }
/** POST /api/conversations/:id/messages */ /** POST /api/conversations/:id/messages */
export function sendMessage(id: string, payload: SendMessagePayload & { translatedText?: string; autoTranslate?: boolean }) { export function sendMessage(
return http.post<Message>(`/api/conversations/${id}/messages`, payload) 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( export function sendMedia(
id: string, 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( export function sendAlbum(
@@ -60,11 +119,28 @@ export function sendAlbum(
} }
/** PATCH /api/conversations/:id(标记已读/分配/状态) */ /** 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 }) { export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
return http.patch<Conversation>(`/api/conversations/${id}`, payload) return http.patch<Conversation>(`/api/conversations/${id}`, payload)
} }
export type TelegramConversationAction = export type ConversationAction =
| 'clear' | 'clear'
| 'delete' | 'delete'
| 'pin' | 'pin'
@@ -78,9 +154,9 @@ export type TelegramConversationAction =
| 'block' | 'block'
| 'unblock' | 'unblock'
export function runTelegramConversationAction( export function runConversationAction(
id: string, id: string,
action: TelegramConversationAction, action: ConversationAction,
options: { seconds?: number } = {}, options: { seconds?: number } = {},
) { ) {
return http.post<Conversation | { success: true; deleted: true }>( return http.post<Conversation | { success: true; deleted: true }>(
@@ -99,3 +175,42 @@ export function openTelegramConversation(payload: {
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 } productCard?: { name: string; sku: string; price: string }
replyToId?: string replyToId?: string
replyPreview?: string replyPreview?: string
editedAt?: string
isDeleted?: boolean
deletedAt?: string
reactions?: Array<{
actor: string
emoji: string
fromMe?: boolean
updatedAt?: string
}>
telegramFileId?: number telegramFileId?: number
whatsappMessageId?: string
whatsappContentType?: string
attachmentMimeType?: string attachmentMimeType?: string
attachmentSize?: number attachmentSize?: number
stickerEmoji?: string stickerEmoji?: string
@@ -74,12 +85,17 @@ export interface Conversation {
channelId?: string channelId?: string
telegramChatId?: string telegramChatId?: string
telegramChatType?: string telegramChatType?: string
whatsappJid?: string
whatsappChatType?: string
isPinned?: boolean isPinned?: boolean
archived?: boolean archived?: boolean
mutedUntil?: string | null mutedUntil?: string | null
markedUnread?: boolean markedUnread?: boolean
isBlocked?: boolean isBlocked?: boolean
leftAt?: string leftAt?: string
presence?: 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
presenceUpdatedAt?: string
lastSeenAt?: string
} }
export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected' export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
@@ -106,6 +122,10 @@ export interface ChannelConnection {
smtpSecure?: boolean smtpSecure?: boolean
syncDays?: number syncDays?: number
lastVerifiedAt?: string lastVerifiedAt?: string
isDefault?: boolean
provider?: string
pairingCode?: string
qrDataUrl?: string
} }
export interface ChannelSyncLog { export interface ChannelSyncLog {
+79 -12
View File
@@ -11,6 +11,27 @@ async function login(page: Page, loginAccount = account, loginPassword = passwor
await expect(page).toHaveURL(/\/dashboard$/) 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) => { test('Telegram 手机号验证码和二步密码登录向导', async ({ page, request }, testInfo) => {
const suffix = `${Date.now()}-${testInfo.project.name}` const suffix = `${Date.now()}-${testInfo.project.name}`
const loginAccount = `channel-e2e-${suffix}@local.test` const loginAccount = `channel-e2e-${suffix}@local.test`
@@ -20,8 +41,11 @@ test('Telegram 手机号验证码和二步密码登录向导', async ({ page, re
}) })
expect(response.ok()).toBeTruthy() expect(response.ok()).toBeTruthy()
await login(page, loginAccount, loginPassword) await login(page, loginAccount, loginPassword)
await page.goto('/channels?setup=telegram') await page.goto('/channels?setup=choose')
await expect(page.getByTestId('telegram-onboarding')).toContainText('先绑定 Telegram') 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.getByPlaceholder('+8613800138000')).toBeVisible()
await expect(page.getByLabel('Telegram 验证码', { exact: true })).toHaveCount(0) await expect(page.getByLabel('Telegram 验证码', { exact: true })).toHaveCount(0)
const sendTelegramCode = page.getByRole('button', { name: '发送 Telegram 验证码' }) const sendTelegramCode = page.getByRole('button', { name: '发送 Telegram 验证码' })
@@ -29,10 +53,29 @@ test('Telegram 手机号验证码和二步密码登录向导', async ({ page, re
await sendTelegramCode.click() await sendTelegramCode.click()
await expect(page.getByText('请输入国际格式手机号,例如 +8613800138000')).toBeVisible() await expect(page.getByText('请输入国际格式手机号,例如 +8613800138000')).toBeVisible()
await expect(page.getByPlaceholder('+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 }) 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) => { test('企业邮箱 IMAP 和 SMTP 连接向导', async ({ page, request }, testInfo) => {
const suffix = `${Date.now()}-${testInfo.project.name}` const suffix = `${Date.now()}-${testInfo.project.name}`
const loginAccount = `email-e2e-${suffix}@local.test` 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 emailCard.getByRole('button', { name: '连接', exact: true }).click()
await expect(page.getByText('IMAP 收信 + SMTP 发信', { exact: true })).toBeVisible() await expect(page.getByText('IMAP 收信 + SMTP 发信', { exact: true })).toBeVisible()
await expect(page.getByPlaceholder('sales@company.com')).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 expect(page.getByRole('button', { name: '验证并连接邮箱' })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('email-connection-wizard.png'), fullPage: true }) 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) => { test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
await login(page) await login(page)
await expect(page).toHaveURL(/\/dashboard$/) 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.screenshot({ path: testInfo.outputPath('channels.png'), fullPage: true })
await page.goto('/inbox') await page.goto('/inbox')
await page.getByRole('button', { name: '添加 Telegram 用户或群组' }).click() await page.getByRole('button', { name: '添加联系人或群组' }).click()
await expect(page.getByRole('dialog')).toContainText('添加 Telegram 会话') await expect(page.getByRole('dialog')).toContainText('添加联系人或群组')
await expect(page.getByLabel('用户名、手机号或用户 ID')).toBeVisible() await expect(page.getByLabel('用户名、手机号或用户 ID')).toBeVisible()
await page.getByRole('button', { name: '群组/频道', exact: true }).click() await page.getByRole('button', { name: '群组/频道', exact: true }).click()
await expect(page.getByLabel('群组/频道用户名或 Chat ID')).toBeVisible() await expect(page.getByLabel('群组/频道用户名或 Chat ID')).toBeVisible()
@@ -71,8 +133,9 @@ test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
await expect(page.getByLabel('Telegram 邀请链接')).toBeVisible() await expect(page.getByLabel('Telegram 邀请链接')).toBeVisible()
await page.getByRole('button', { name: '关闭', exact: true }).click() await page.getByRole('button', { name: '关闭', exact: true }).click()
const privateConversation = page.locator( 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() ).first()
await expect(privateConversation).toBeVisible()
await privateConversation.click() await privateConversation.click()
await expect(page.getByTestId('message-content')).toHaveClass(/justify-end/) await expect(page.getByTestId('message-content')).toHaveClass(/justify-end/)
const languageSwitcher = page.getByRole('radiogroup', { name: '消息显示语言' }) const languageSwitcher = page.getByRole('radiogroup', { name: '消息显示语言' })
@@ -88,11 +151,15 @@ test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
await expect(outgoingSurface.first()).toHaveClass(/telegram-bubble-out/) await expect(outgoingSurface.first()).toHaveClass(/telegram-bubble-out/)
const incomingSurface = page.locator('[data-testid="message-surface"][data-direction="incoming"]') const incomingSurface = page.locator('[data-testid="message-surface"][data-direction="incoming"]')
if (await incomingSurface.count()) await expect(incomingSurface.first()).toHaveClass(/telegram-bubble-in/) if (await incomingSurface.count()) await expect(incomingSurface.first()).toHaveClass(/telegram-bubble-in/)
if (testInfo.project.name !== 'mobile-chromium') {
await expect(privateConversation).toHaveClass(/conversation-selected/) await expect(privateConversation).toHaveClass(/conversation-selected/)
await expect(page.getByTestId('conversation-list-panel')).toHaveClass(/border-slate-300/) await expect(page.getByTestId('conversation-list-panel')).toHaveClass(/border-slate-300/)
await expect(page.getByTestId('chat-panel')).toHaveClass(/bg-\[#eaf2f9\]/) 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').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 page.getByRole('button', { name: 'Telegram 会话操作' }).click()
await expect(page.getByText(/置顶会话|取消置顶/)).toBeVisible() await expect(page.getByText(/置顶会话|取消置顶/)).toBeVisible()
await expect(page.getByText(/归档会话|移出归档/)).toBeVisible() await expect(page.getByText(/归档会话|移出归档/)).toBeVisible()
@@ -192,12 +259,12 @@ test('客户画像雷达与采购情报可视化', async ({ page }, testInfo) =>
await expect(rows.first()).toBeVisible() await expect(rows.first()).toBeVisible()
await rows.first().getByRole('cell').nth(1).click() await rows.first().getByRole('cell').nth(1).click()
await expect(page.getByText('客户价值雷达', { exact: true })).toBeVisible() 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() await expect(page.getByText('标签画像', { exact: true })).toBeVisible()
const semanticTags = page.locator('[data-tag-category]') const semanticTags = page.locator('[data-tag-category]')
if (await semanticTags.count()) await expect(semanticTags.first()).toBeVisible() 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 }) 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.screenshot({ path: testInfo.outputPath('team.png'), fullPage: true })
await page.goto('/billing') 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 }) 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 })
}
})