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