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