feat(history): complete messaging core before multi-account gateways
This commit is contained in:
+15
-30
@@ -5,21 +5,6 @@ import type { ReactNode } from 'react'
|
||||
|
||||
import { LoginPage } from '@/pages/auth/LoginPage'
|
||||
import { RegisterPage } from '@/pages/auth/RegisterPage'
|
||||
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
|
||||
import { InboxPage } from '@/pages/inbox/InboxPage'
|
||||
import { CustomersPage } from '@/pages/customers/CustomersPage'
|
||||
import { CustomerDetailPage } from '@/pages/customers/CustomerDetailPage'
|
||||
import { OpportunitiesPage } from '@/pages/opportunities/OpportunitiesPage'
|
||||
import { TranslatePage } from '@/pages/translate/TranslatePage'
|
||||
import { RisksPage } from '@/pages/risks/RisksPage'
|
||||
import { KnowledgePage } from '@/pages/knowledge/KnowledgePage'
|
||||
import { AnalyticsPage } from '@/pages/analytics/AnalyticsPage'
|
||||
import { ChannelsPage } from '@/pages/channels/ChannelsPage'
|
||||
import { TeamPage } from '@/pages/team/TeamPage'
|
||||
import { BillingPage } from '@/pages/billing/BillingPage'
|
||||
import { SettingsPage } from '@/pages/settings/SettingsPage'
|
||||
import { HelpPage } from '@/pages/help/HelpPage'
|
||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
@@ -45,21 +30,21 @@ const router = createBrowserRouter([
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'inbox', element: <InboxPage /> },
|
||||
{ path: 'customers', element: <CustomersPage /> },
|
||||
{ path: 'customers/:id', element: <CustomerDetailPage /> },
|
||||
{ path: 'opportunities', element: <OpportunitiesPage /> },
|
||||
{ path: 'translate', element: <TranslatePage /> },
|
||||
{ path: 'risks', element: <RisksPage /> },
|
||||
{ path: 'knowledge', element: <KnowledgePage /> },
|
||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||
{ path: 'channels', element: <ChannelsPage /> },
|
||||
{ path: 'team', element: <TeamPage /> },
|
||||
{ path: 'billing', element: <BillingPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'help', element: <HelpPage /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
{ path: 'dashboard', lazy: async () => ({ Component: (await import('@/pages/dashboard/DashboardPage')).DashboardPage }) },
|
||||
{ path: 'inbox', lazy: async () => ({ Component: (await import('@/pages/inbox/InboxPage')).InboxPage }) },
|
||||
{ path: 'customers', lazy: async () => ({ Component: (await import('@/pages/customers/CustomersPage')).CustomersPage }) },
|
||||
{ path: 'customers/:id', lazy: async () => ({ Component: (await import('@/pages/customers/CustomerDetailPage')).CustomerDetailPage }) },
|
||||
{ path: 'opportunities', lazy: async () => ({ Component: (await import('@/pages/opportunities/OpportunitiesPage')).OpportunitiesPage }) },
|
||||
{ path: 'translate', lazy: async () => ({ Component: (await import('@/pages/translate/TranslatePage')).TranslatePage }) },
|
||||
{ path: 'risks', lazy: async () => ({ Component: (await import('@/pages/risks/RisksPage')).RisksPage }) },
|
||||
{ path: 'knowledge', lazy: async () => ({ Component: (await import('@/pages/knowledge/KnowledgePage')).KnowledgePage }) },
|
||||
{ path: 'analytics', lazy: async () => ({ Component: (await import('@/pages/analytics/AnalyticsPage')).AnalyticsPage }) },
|
||||
{ path: 'channels', lazy: async () => ({ Component: (await import('@/pages/channels/ChannelsPage')).ChannelsPage }) },
|
||||
{ path: 'team', lazy: async () => ({ Component: (await import('@/pages/team/TeamPage')).TeamPage }) },
|
||||
{ path: 'billing', lazy: async () => ({ Component: (await import('@/pages/billing/BillingPage')).BillingPage }) },
|
||||
{ path: 'settings', lazy: async () => ({ Component: (await import('@/pages/settings/SettingsPage')).SettingsPage }) },
|
||||
{ path: 'help', lazy: async () => ({ Component: (await import('@/pages/help/HelpPage')).HelpPage }) },
|
||||
{ path: '*', lazy: async () => ({ Component: (await import('@/pages/NotFoundPage')).NotFoundPage }) },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -18,7 +18,7 @@ import { Card, CardHeader } from '@/components/ui/card'
|
||||
import { LiveDataBadge } from '@/components/common/LiveDataBadge'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export const CHART_COLORS = ['#247b71', '#32998c', '#52b3a5', '#83cfc2', '#f59e0b', '#3b82f6', '#8b5cf6', '#94a3b8']
|
||||
export const CHART_COLORS = ['#2563eb', '#3b82f6', '#60a5fa', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#94a3b8']
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
|
||||
@@ -27,17 +27,20 @@ export function StatCard({
|
||||
const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown
|
||||
return (
|
||||
<Card
|
||||
className={cn('p-4', onClick && 'cursor-pointer transition-shadow hover:shadow-md')}
|
||||
className={cn(
|
||||
'group p-5 transition-all duration-200',
|
||||
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_14px_35px_rgba(15,23,42,.07)]',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{title}</span>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50">
|
||||
<span className="text-xs font-medium text-slate-500">{title}</span>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-50 transition-colors group-hover:bg-primary-100">
|
||||
<Icon className="h-4 w-4 text-primary-600" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className="text-2xl font-semibold tracking-tight text-foreground">{value}</span>
|
||||
<div className="mt-3 flex items-baseline gap-1">
|
||||
<span className="text-[28px] font-semibold tracking-[-0.04em] text-slate-950">{value}</span>
|
||||
{suffix && <span className="text-xs text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
{showDelta && (
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useId } from 'react'
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
RadarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import { Sparkles, TrendingUp } from 'lucide-react'
|
||||
import type { Customer } from '@/types'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
function qualitativeScore(value?: string, fallback = 50) {
|
||||
const text = String(value || '').toLowerCase()
|
||||
if (!text) return fallback
|
||||
if (/高|强|紧急|决策|老板|owner|director|high|urgent/.test(text)) return 86
|
||||
if (/中|一般|参与|medium|normal/.test(text)) return 62
|
||||
if (/低|弱|未知|助理|low|unknown/.test(text)) return 36
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function profileRadarData(customer: Customer) {
|
||||
const profile = customer.aiProfile || {}
|
||||
const extracted = [
|
||||
profile.companyName,
|
||||
profile.industry,
|
||||
profile.businessModel,
|
||||
profile.inquiryQuantity,
|
||||
profile.targetPrice,
|
||||
profile.budget,
|
||||
profile.purchaseTimeline,
|
||||
profile.payment,
|
||||
].filter(Boolean).length
|
||||
const fitScore = Math.min(96, 42 + extracted * 6 + (profile.keyNeeds?.length || 0) * 4)
|
||||
|
||||
return [
|
||||
{ dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' },
|
||||
{ dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: 'AI 综合预测成交概率' },
|
||||
{ dimension: '决策能力', score: qualitativeScore(profile.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' },
|
||||
{ dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' },
|
||||
{ dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' },
|
||||
{ dimension: '关系健康', score: Math.max(0, 100 - Number(profile.churnProbability || 0)), hint: '流失风险的反向健康度' },
|
||||
]
|
||||
}
|
||||
|
||||
export function CustomerProfileRadar({
|
||||
customer,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
customer: Customer
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const data = profileRadarData(customer)
|
||||
const average = Math.round(data.reduce((sum, item) => sum + item.score, 0) / data.length)
|
||||
const chartId = useId().replace(/:/g, '')
|
||||
const gradientId = `radar-fill-${chartId}`
|
||||
const glowId = `radar-glow-${chartId}`
|
||||
const level = average >= 75 ? '高潜客户' : average >= 55 ? '重点培育' : '持续观察'
|
||||
const levelTone = average >= 75
|
||||
? 'text-blue-700 bg-blue-50 border-blue-100'
|
||||
: average >= 55
|
||||
? 'text-violet-700 bg-violet-50 border-violet-100'
|
||||
: 'text-amber-700 bg-amber-50 border-amber-100'
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'group relative isolate overflow-hidden rounded-2xl border border-blue-100/90 bg-white shadow-[0_18px_55px_-32px_rgba(37,99,235,.32)]',
|
||||
className,
|
||||
)}>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-40 bg-[radial-gradient(circle_at_75%_0%,rgba(96,165,250,.16),transparent_58%)]" />
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-violet-600 text-white shadow-[0_8px_20px_-8px_rgba(37,99,235,.7)]">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight text-slate-950">客户价值雷达</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">AI 六维购买力画像 · 随会话实时更新</p>
|
||||
</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)}>
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{level}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="relative flex h-[70px] w-[70px] shrink-0 items-center justify-center rounded-full p-[5px] shadow-[0_10px_30px_-16px_rgba(37,99,235,.7)]"
|
||||
style={{ background: `conic-gradient(#2563eb ${average * 3.6}deg, #dbeafe 0deg)` }}
|
||||
aria-label={`综合指数 ${average} 分`}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-full border border-white bg-white shadow-inner">
|
||||
<p className="text-[9px] font-semibold tracking-wider text-blue-600">综合指数</p>
|
||||
<p className="text-2xl font-bold leading-6 tracking-tight text-slate-950">{average}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'customer-radar-stage relative mx-3 mt-1 overflow-hidden rounded-2xl',
|
||||
'bg-[radial-gradient(circle_at_center,rgba(239,246,255,.96)_0%,rgba(255,255,255,.45)_62%,transparent_72%)]',
|
||||
compact ? 'h-[240px]' : 'h-[310px]',
|
||||
)}>
|
||||
<div className="pointer-events-none absolute left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 rounded-full border border-blue-100/80 shadow-[0_0_60px_rgba(37,99,235,.10)]" />
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={data} cx="50%" cy="52%" outerRadius={compact ? '66%' : '72%'}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#60a5fa" stopOpacity="0.58" />
|
||||
<stop offset="55%" stopColor="#3b82f6" stopOpacity="0.34" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0.22" />
|
||||
</linearGradient>
|
||||
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<PolarGrid stroke="#dbeafe" strokeOpacity={0.95} gridType="polygon" />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fill: '#334155', fontSize: compact ? 10 : 12, fontWeight: 650 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value} / 100`, '画像指数']}
|
||||
labelFormatter={(label) => `${label} · AI 评估`}
|
||||
contentStyle={{
|
||||
borderRadius: 14,
|
||||
border: '1px solid #dbeafe',
|
||||
boxShadow: '0 18px 40px rgba(37,99,235,.14)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Radar
|
||||
name="客户画像光晕"
|
||||
dataKey="score"
|
||||
isAnimationActive={false}
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={8}
|
||||
strokeOpacity={0.16}
|
||||
fill="none"
|
||||
dot={false}
|
||||
filter={`url(#${glowId})`}
|
||||
/>
|
||||
<Radar
|
||||
name="客户画像"
|
||||
dataKey="score"
|
||||
isAnimationActive={false}
|
||||
stroke="#2563eb"
|
||||
strokeWidth={3}
|
||||
fill={`url(#${gradientId})`}
|
||||
fillOpacity={1}
|
||||
dot={{ r: compact ? 3.5 : 4.5, fill: '#2563eb', strokeWidth: 3, stroke: '#fff' }}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-slate-100 bg-slate-50/40 px-4 py-4 sm:grid-cols-3">
|
||||
{data.map((item) => (
|
||||
<div key={item.dimension} className="min-w-0 rounded-xl border border-slate-100 bg-white px-3 py-2.5 shadow-sm">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<span className="text-[11px] font-medium text-slate-500">{item.dimension}</span>
|
||||
<span className="text-base font-bold leading-none text-slate-900">{item.score}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-blue-400 via-blue-600 to-violet-500 shadow-[0_0_8px_rgba(37,99,235,.28)]"
|
||||
style={{ width: `${item.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Tag } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
type TagCategory = 'positive' | 'emotion' | 'risk' | 'aftersales' | 'quality' | 'service' | 'profile'
|
||||
|
||||
const categoryConfig: Record<TagCategory, { label: string; className: string; dot: string }> = {
|
||||
positive: {
|
||||
label: '积极信号',
|
||||
className: 'border-emerald-200/90 bg-emerald-50 text-emerald-700',
|
||||
dot: 'bg-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,.12)]',
|
||||
},
|
||||
emotion: {
|
||||
label: '客户情绪',
|
||||
className: 'border-amber-200/90 bg-amber-50 text-amber-700',
|
||||
dot: 'bg-amber-500 shadow-[0_0_0_2px_rgba(245,158,11,.12)]',
|
||||
},
|
||||
risk: {
|
||||
label: '投诉风险',
|
||||
className: 'border-rose-200/90 bg-rose-50 text-rose-700',
|
||||
dot: 'bg-rose-500 shadow-[0_0_0_2px_rgba(244,63,94,.12)]',
|
||||
},
|
||||
aftersales: {
|
||||
label: '售后处理',
|
||||
className: 'border-orange-200/90 bg-orange-50 text-orange-700',
|
||||
dot: 'bg-orange-500 shadow-[0_0_0_2px_rgba(249,115,22,.12)]',
|
||||
},
|
||||
quality: {
|
||||
label: '产品质量',
|
||||
className: 'border-sky-200/90 bg-sky-50 text-sky-700',
|
||||
dot: 'bg-sky-500 shadow-[0_0_0_2px_rgba(14,165,233,.12)]',
|
||||
},
|
||||
service: {
|
||||
label: '服务沟通',
|
||||
className: 'border-violet-200/90 bg-violet-50 text-violet-700',
|
||||
dot: 'bg-violet-500 shadow-[0_0_0_2px_rgba(139,92,246,.12)]',
|
||||
},
|
||||
profile: {
|
||||
label: '客户属性',
|
||||
className: 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
dot: 'bg-slate-400 shadow-[0_0_0_2px_rgba(100,116,139,.1)]',
|
||||
},
|
||||
}
|
||||
|
||||
export function customerTagCategory(tag: string): TagCategory {
|
||||
const value = tag.toLowerCase().replace(/[_-]+/g, ' ')
|
||||
|
||||
// Positive outcomes take priority for phrases such as “投诉处理成功”.
|
||||
if (/success|satisfaction|repurchase|positive|resolved|回升|缓和|修复|改进|成功|复购|满意|转正/.test(value)) return 'positive'
|
||||
if (/emotion|anger|angry|insult|情绪|愤怒|高情绪|冲突/.test(value)) return 'emotion'
|
||||
if (/urgent|escalation|crisis|complaint|投诉|危机|辱骂|欺诈|风险/.test(value)) return 'risk'
|
||||
if (/after.?sales|refund|return|replace|售后|退款|退货|补偿|换货|更换/.test(value)) return 'aftersales'
|
||||
if (/quality|defect|product|holes?|smell|品质|质量|瑕疵|破损|异味|制造|尺寸|材质/.test(value)) return 'quality'
|
||||
if (/agent|service|communication|feedback|客服|坐席|沟通|销售反馈/.test(value)) return 'service'
|
||||
return 'profile'
|
||||
}
|
||||
|
||||
export function CustomerSemanticTag({
|
||||
tag,
|
||||
className,
|
||||
}: {
|
||||
tag: string
|
||||
className?: string
|
||||
}) {
|
||||
const category = customerTagCategory(tag)
|
||||
const config = categoryConfig[category]
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tag-category={category}
|
||||
title={`${config.label} · ${tag}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-medium leading-none transition-transform hover:-translate-y-px',
|
||||
config.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', config.dot)} />
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomerTagLegend({ tags }: { tags: string[] }) {
|
||||
const counts = tags.reduce<Partial<Record<TagCategory, number>>>((result, tag) => {
|
||||
const category = customerTagCategory(tag)
|
||||
result[category] = (result[category] || 0) + 1
|
||||
return result
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[10px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1 font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
<Tag className="h-3 w-3" />
|
||||
标签画像
|
||||
</span>
|
||||
{(Object.keys(categoryConfig) as TagCategory[])
|
||||
.filter((category) => counts[category])
|
||||
.map((category) => (
|
||||
<span key={category} className="inline-flex items-center gap-1">
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', categoryConfig[category].dot)} />
|
||||
{categoryConfig[category].label}
|
||||
<span className="font-semibold text-slate-700">{counts[category]}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, X } from 'lucide-react'
|
||||
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, Languages, X } from 'lucide-react'
|
||||
import { generateReplies } from '@/services/api/ai'
|
||||
import type { AiReplySuggestion } from '@/types/ai'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -22,8 +22,8 @@ export function AiReplies({
|
||||
onClose,
|
||||
}: {
|
||||
conversationId: string
|
||||
onAdopt: (content: string) => void
|
||||
onEdit: (content: string) => void
|
||||
onAdopt: (content: string, translationCn: string) => void
|
||||
onEdit: (content: string, translationCn: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
|
||||
@@ -39,10 +39,10 @@ export function AiReplies({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="border-t border-slate-100 bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||
<h4 className="flex items-center gap-2 text-xs font-semibold text-slate-700">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary-50"><Sparkles className="h-3.5 w-3.5 text-primary" /></span>
|
||||
AI 智能回复
|
||||
</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -94,7 +94,29 @@ export function AiReplies({
|
||||
{languageName(s.targetLang)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{s.content}</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
<Globe className="h-3 w-3" />
|
||||
发送给客户 · English
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-800">{s.content}</p>
|
||||
</div>
|
||||
{s.translationCn && (
|
||||
<div
|
||||
data-testid="ai-reply-translation"
|
||||
className="rounded-lg border border-primary-100 bg-primary-50/60 px-3 py-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold text-primary-700">
|
||||
<Languages className="h-3 w-3" />
|
||||
中文参考 · 不会发送
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-slate-600">
|
||||
{s.translationCn}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{(s.knowledgeRefs || []).slice(0, 2).map((ref) => (
|
||||
@@ -111,7 +133,7 @@ export function AiReplies({
|
||||
<span className="text-muted-foreground">{s.reason}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1">
|
||||
<Globe className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
|
||||
<Sparkles className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-muted-foreground">{s.expectedEffect}</span>
|
||||
</div>
|
||||
{s.cultureTip && (
|
||||
@@ -123,11 +145,11 @@ export function AiReplies({
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button size="sm" variant="default" onClick={() => onAdopt(s.content)}>
|
||||
<Button size="sm" variant="default" onClick={() => onAdopt(s.content, s.translationCn)}>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
采用
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onEdit(s.content)}>
|
||||
<Button size="sm" variant="outline" onClick={() => onEdit(s.content, s.translationCn)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
编辑后采用
|
||||
</Button>
|
||||
@@ -146,9 +168,10 @@ export function AiReplies({
|
||||
)}
|
||||
|
||||
{!items && !mutation.isPending && (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
点击「生成回复建议」让 AI 根据客户上下文、知识库与历史成功回复为您生成回复
|
||||
</p>
|
||||
<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>
|
||||
<span className="hidden text-[10px] font-medium text-primary sm:inline">不会自动发送</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Dropdown } from '@/components/ui/dropdown'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toast } from '@/app/store/toast'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { ChannelType } from '@/types'
|
||||
|
||||
const QUICK_PHRASES = [
|
||||
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' },
|
||||
@@ -47,6 +47,7 @@ export function Composer({
|
||||
disabled,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
channel = 'telegram',
|
||||
}: {
|
||||
onSend: (text: string, autoTranslate?: boolean) => Promise<void>
|
||||
onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise<void>
|
||||
@@ -55,6 +56,7 @@ export function Composer({
|
||||
disabled?: boolean
|
||||
replyingTo?: { id: string; text: string } | null
|
||||
onCancelReply?: () => void
|
||||
channel?: ChannelType
|
||||
}) {
|
||||
const [internalText, setInternalText] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
@@ -73,7 +75,7 @@ export function Composer({
|
||||
setSending(true)
|
||||
try {
|
||||
await onMedia(Array.from(files), kind)
|
||||
toast.success(`${files.length} 个文件已发送到 Telegram`)
|
||||
toast.success(`${files.length} 个文件已发送到${channel === 'email' ? '客户邮箱' : ' Telegram'}`)
|
||||
} catch {
|
||||
toast.error('媒体发送失败')
|
||||
} finally {
|
||||
@@ -120,11 +122,11 @@ export function Composer({
|
||||
}
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 py-1.5">
|
||||
<div className="flex items-center gap-1 overflow-x-auto border-b border-slate-100 px-3 py-2 scrollbar-thin">
|
||||
{/* 话术 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<button className="flex shrink-0 items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
常用话术
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
@@ -134,7 +136,7 @@ export function Composer({
|
||||
/>
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
<button
|
||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => documentInput.current?.click()}
|
||||
title="上传文件"
|
||||
type="button"
|
||||
@@ -142,29 +144,33 @@ export function Composer({
|
||||
<Paperclip className="inline h-3.5 w-3.5" /> 文件
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => imageInput.current?.click()}
|
||||
title="上传图片"
|
||||
type="button"
|
||||
>
|
||||
<Image className="inline h-3.5 w-3.5" /> 图片
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => audioInput.current?.click()}
|
||||
title="语音输入"
|
||||
type="button"
|
||||
>
|
||||
<Mic className="inline h-3.5 w-3.5" /> 语音
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => stickerInput.current?.click()}
|
||||
title="发送贴纸"
|
||||
type="button"
|
||||
>
|
||||
贴纸
|
||||
</button>
|
||||
{channel !== 'email' && (
|
||||
<>
|
||||
<button
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => audioInput.current?.click()}
|
||||
title="语音输入"
|
||||
type="button"
|
||||
>
|
||||
<Mic className="inline h-3.5 w-3.5" /> 语音
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => stickerInput.current?.click()}
|
||||
title="发送贴纸"
|
||||
type="button"
|
||||
>
|
||||
贴纸
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<input ref={documentInput} type="file" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'document')} />
|
||||
<input ref={imageInput} type="file" accept="image/*" multiple className="hidden" onChange={(e) => uploadFiles(e.target.files, 'photo')} />
|
||||
<input ref={audioInput} type="file" accept="audio/*" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'voice')} />
|
||||
@@ -182,7 +188,7 @@ export function Composer({
|
||||
type="button"
|
||||
disabled={aiLoading || !text.trim()}
|
||||
onClick={() => aiAction(t.action)}
|
||||
className="rounded px-1.5 py-1 text-xs text-teal-600 hover:bg-teal-50 disabled:opacity-50"
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50 disabled:opacity-50"
|
||||
>
|
||||
<t.icon className="inline h-3.5 w-3.5" /> {t.label}
|
||||
</button>
|
||||
@@ -190,7 +196,7 @@ export function Composer({
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted">
|
||||
<button className="flex shrink-0 items-center gap-0.5 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50">
|
||||
修改语气 <ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
}
|
||||
@@ -200,9 +206,9 @@ export function Composer({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card">
|
||||
<div className="border-t border-slate-100 bg-white">
|
||||
{toolbar}
|
||||
<div className="p-3">
|
||||
<div className="p-3.5">
|
||||
{replyingTo && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-lg border-s-2 border-primary bg-primary-50/70 px-3 py-2">
|
||||
<Reply className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
@@ -220,9 +226,9 @@ export function Composer({
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入回复内容,Enter 发送,Shift+Enter 换行"
|
||||
placeholder={channel === 'email' ? '撰写邮件回复,Enter 发送,Shift+Enter 换行' : '输入回复内容,Enter 发送,Shift+Enter 换行'}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-ring scrollbar-thin"
|
||||
className="min-h-[86px] w-full resize-none rounded-2xl border border-slate-200 bg-slate-50/60 px-4 py-3.5 text-sm leading-6 placeholder:text-slate-400 outline-none transition-all focus:border-primary-300 focus:bg-white focus:ring-4 focus:ring-primary-50 scrollbar-thin"
|
||||
disabled={sending || disabled}
|
||||
/>
|
||||
|
||||
@@ -230,7 +236,7 @@ export function Composer({
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Switch checked={autoTranslate} onCheckedChange={setAutoTranslate} />
|
||||
中文自动译为英文发送
|
||||
中文自动译为英文{channel === 'email' ? '邮件' : '发送'}
|
||||
</label>
|
||||
{aiLoading && (
|
||||
<Badge variant="primary" className="animate-pulse">
|
||||
@@ -240,7 +246,7 @@ export function Composer({
|
||||
</div>
|
||||
<Button onClick={send} size="sm" disabled={!text.trim() || sending || disabled} loading={sending}>
|
||||
<Send className="h-4 w-4" />
|
||||
发 送 (↵)
|
||||
{channel === 'email' ? '发送邮件' : '发 送'} (↵)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Brain, Building2, ChevronRight } from 'lucide-react'
|
||||
import { Brain, ChevronRight } from 'lucide-react'
|
||||
import type { Conversation, Customer, CustomerStage } from '@/types'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CountryFlag } from '@/components/common/CountryFlag'
|
||||
import {
|
||||
CustomerStageBadge,
|
||||
IntentScoreBadge,
|
||||
@@ -28,30 +26,21 @@ export function ConversationInsightBar({
|
||||
const summary = customer.aiProfile?.summary || conversation.summary || 'AI 正在根据会话完善客户画像'
|
||||
|
||||
return (
|
||||
<div data-testid="conversation-insight-bar" className="border-b border-border bg-gradient-to-r from-primary-50/80 via-card to-card px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<Avatar name={customer.name} src={customer.avatarUrl} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="max-w-40 truncate text-sm font-semibold">{customer.name}</span>
|
||||
{customer.telegramUsername && (
|
||||
<span className="max-w-32 truncate text-[11px] text-muted-foreground">
|
||||
@{customer.telegramUsername}
|
||||
</span>
|
||||
)}
|
||||
<CountryFlag code={customer.country} withName={false} />
|
||||
{customer.company && (
|
||||
<span className="hidden items-center gap-1 truncate text-xs text-muted-foreground md:flex">
|
||||
<Building2 className="h-3 w-3" /> {customer.company}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 max-w-[60vw] truncate text-[11px] text-muted-foreground">
|
||||
<Brain className="mr-1 inline h-3 w-3 text-primary" />
|
||||
{summary}
|
||||
</p>
|
||||
<div
|
||||
data-testid="conversation-insight-bar"
|
||||
className="shrink-0 border-b border-slate-100 bg-white px-3 py-2 shadow-[0_1px_0_rgba(15,23,42,.02)] sm:px-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-violet-100 bg-violet-50 text-violet-600">
|
||||
<Brain className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
|
||||
AI 客户洞察
|
||||
</span>
|
||||
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
|
||||
</div>
|
||||
<div className="ms-auto hidden items-center gap-2 sm:flex">
|
||||
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
|
||||
</span>
|
||||
@@ -59,13 +48,13 @@ export function ConversationInsightBar({
|
||||
<CustomerStageBadge stage={stage as CustomerStage} />
|
||||
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onOpenProfile}>
|
||||
<Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开客户画像">
|
||||
<span className="hidden lg:inline">客户画像</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-3 overflow-x-auto sm:hidden">
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span>
|
||||
<div className="mt-1.5 flex items-center gap-2 overflow-x-auto pb-0.5 md:hidden">
|
||||
<span className="flex shrink-0 items-center gap-1 text-[11px]">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span>
|
||||
<SentimentBadge sentiment={customer.sentiment || 'neutral'} />
|
||||
<CustomerStageBadge stage={stage as CustomerStage} />
|
||||
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Search, ShieldAlert } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Eraser, Mail, Pin, Plus, Search, Send, ShieldAlert, Trash2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getTeam } from '@/services/api/org'
|
||||
import type { ConversationListItem } from '@/services/api/conversations'
|
||||
@@ -21,6 +22,7 @@ const FILTERS = [
|
||||
{ value: 'risk', label: '有风险' },
|
||||
{ value: 'unassigned', label: '无负责人' },
|
||||
{ value: 'mine', label: '我的客户' },
|
||||
{ value: 'archived', label: '已归档' },
|
||||
]
|
||||
|
||||
export function ConversationList({
|
||||
@@ -32,6 +34,11 @@ export function ConversationList({
|
||||
onFilterChange,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
channel,
|
||||
onChannelChange,
|
||||
onAdd,
|
||||
onClear,
|
||||
onDelete,
|
||||
}: {
|
||||
items: ConversationListItem[]
|
||||
loading: boolean
|
||||
@@ -41,34 +48,82 @@ export function ConversationList({
|
||||
onFilterChange: (f: string) => void
|
||||
keyword: string
|
||||
onKeywordChange: (k: string) => void
|
||||
channel: string
|
||||
onChannelChange: (channel: string) => void
|
||||
onAdd: () => void
|
||||
onClear: (item: ConversationListItem) => void
|
||||
onDelete: (item: ConversationListItem) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 })
|
||||
const memberName = (id: string | null) => (id ? (team?.find((m) => m.id === id)?.name ?? '—') : null)
|
||||
const [swipedId, setSwipedId] = useState<string | null>(null)
|
||||
const [dragOffset, setDragOffset] = useState(0)
|
||||
const touchStart = useRef<{ x: number; y: number; id: string } | null>(null)
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 搜索 */}
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="border-b border-slate-200 bg-white px-3 pb-3 pt-3">
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-[-0.03em] text-slate-950">消息</h1>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">{items.length} 个活跃会话</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold text-emerald-700">
|
||||
实时连接
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
title="添加 Telegram 用户或群组"
|
||||
aria-label="添加 Telegram 用户或群组"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:left-auto rtl:right-2.5" />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => onKeywordChange(e.target.value)}
|
||||
placeholder="搜索客户/国家/产品/消息内容"
|
||||
className="h-9 w-full rounded-md border border-input bg-muted/40 pl-8 pr-2 text-sm placeholder:text-muted-foreground focus:border-primary focus:bg-card focus:outline-none rtl:pl-2 rtl:pr-8"
|
||||
className="h-10 w-full rounded-full border border-transparent bg-slate-100/80 pl-9 pr-3 text-sm placeholder:text-slate-400 focus:border-sky-200 focus:bg-white focus:outline-none focus:ring-4 focus:ring-sky-50 rtl:pl-2 rtl:pr-8"
|
||||
/>
|
||||
</div>
|
||||
{/* 筛选 */}
|
||||
<div className="mt-2.5 flex flex-wrap gap-1">
|
||||
<div className="mt-2.5 flex items-center gap-1 rounded-xl bg-slate-50 p-1">
|
||||
{[
|
||||
{ value: '', label: '全部', icon: null },
|
||||
{ value: 'telegram', label: 'Telegram', icon: Send },
|
||||
{ value: 'email', label: 'Email', icon: Mail },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => onChannelChange(item.value)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-semibold transition-all',
|
||||
channel === item.value
|
||||
? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200/70'
|
||||
: 'text-slate-400 hover:text-slate-700',
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="h-3 w-3" />}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1 overflow-x-auto pb-0.5 scrollbar-none">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-1 text-xs transition-colors',
|
||||
'shrink-0 rounded-full px-3 py-1.5 text-[11px] font-medium transition-colors',
|
||||
filter === f.value
|
||||
? 'bg-primary font-medium text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-border',
|
||||
? 'bg-primary font-semibold text-white shadow-sm shadow-primary-200'
|
||||
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-800',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
@@ -78,10 +133,10 @@ export function ConversationList({
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className="flex-1 overflow-y-auto bg-white px-1.5 py-1.5 scrollbar-thin">
|
||||
{loading &&
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3 border-b border-border px-3 py-3">
|
||||
<div key={i} className="flex gap-3 rounded-xl px-3 py-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3.5 w-2/3" />
|
||||
@@ -97,17 +152,80 @@ export function ConversationList({
|
||||
const c = item.customer
|
||||
const active = item.id === activeId
|
||||
const intent = intentLevelMeta(item.intentLevel)
|
||||
const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel'
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-testid="conversation-item"
|
||||
data-conversation-id={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
'flex w-full gap-2.5 border-b border-border px-3 py-3 text-start transition-colors',
|
||||
active ? 'bg-primary-50/70' : 'hover:bg-muted/50',
|
||||
<div key={item.id} className="relative mb-0.5 overflow-hidden rounded-xl">
|
||||
{item.channel === 'telegram' && (
|
||||
<div
|
||||
data-testid="conversation-swipe-actions"
|
||||
aria-hidden={swipedId !== item.id && touchStart.current?.id !== item.id}
|
||||
className={cn(
|
||||
'absolute inset-y-0 right-0 flex w-[144px] overflow-hidden rounded-xl transition-opacity duration-150',
|
||||
swipedId === item.id || touchStart.current?.id === item.id
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
{!isTelegramChannel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClear(item)}
|
||||
className="flex w-[72px] flex-col items-center justify-center gap-1 bg-amber-500 text-[11px] font-semibold text-white"
|
||||
>
|
||||
<Eraser className="h-4 w-4" />清空
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(item)}
|
||||
className="flex flex-1 flex-col items-center justify-center gap-1 bg-red-500 text-[11px] font-semibold text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />{isTelegramChannel ? '退出并删除' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<button
|
||||
data-testid="conversation-item"
|
||||
data-conversation-id={item.id}
|
||||
data-channel={item.channel}
|
||||
data-telegram-chat-type={item.telegramChatType || ''}
|
||||
onTouchStart={(event) => {
|
||||
if (item.channel !== 'telegram') return
|
||||
const touch = event.touches[0]
|
||||
touchStart.current = { x: touch.clientX, y: touch.clientY, id: item.id }
|
||||
if (swipedId !== item.id) setDragOffset(0)
|
||||
}}
|
||||
onTouchMove={(event) => {
|
||||
const start = touchStart.current
|
||||
if (!start || start.id !== item.id) return
|
||||
const touch = event.touches[0]
|
||||
const dx = touch.clientX - start.x
|
||||
const dy = touch.clientY - start.y
|
||||
if (Math.abs(dx) <= Math.abs(dy)) return
|
||||
setDragOffset(Math.max(-144, Math.min(0, (swipedId === item.id ? -144 : 0) + dx)))
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
setSwipedId(dragOffset < -54 ? item.id : null)
|
||||
setDragOffset(0)
|
||||
touchStart.current = null
|
||||
}}
|
||||
onClick={() => {
|
||||
if (swipedId === item.id) {
|
||||
setSwipedId(null)
|
||||
return
|
||||
}
|
||||
onSelect(item.id)
|
||||
}}
|
||||
style={{
|
||||
transform: `translateX(${swipedId === item.id ? -144 : touchStart.current?.id === item.id ? dragOffset : 0}px)`,
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex w-full gap-3 overflow-hidden rounded-xl px-3 py-3 text-start transition-[transform,background-color,box-shadow] duration-200',
|
||||
active
|
||||
? 'conversation-selected bg-gradient-to-br from-primary-200/80 via-primary-100/90 to-white text-foreground ring-1 ring-inset ring-primary-300'
|
||||
: 'bg-white hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar name={c.name} src={c.avatarUrl} size="md" />
|
||||
<span className="absolute -bottom-1 -right-1 rounded-full border border-card bg-card">
|
||||
@@ -116,7 +234,8 @@ export function ConversationList({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{c.name}</span>
|
||||
<span className={cn('truncate text-sm font-semibold', active && 'text-primary-900')}>{c.name}</span>
|
||||
{item.isPinned && <Pin className="h-3 w-3 shrink-0 fill-primary-100 text-primary" />}
|
||||
{c.telegramUsername && (
|
||||
<span className="hidden max-w-24 truncate text-[10px] text-muted-foreground xl:inline">
|
||||
@{c.telegramUsername}
|
||||
@@ -124,30 +243,34 @@ export function ConversationList({
|
||||
)}
|
||||
<CountryFlag code={c.country} withName={false} />
|
||||
{item.hasRisk && <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-red-500" />}
|
||||
<span className="ms-auto shrink-0 text-[11px] text-muted-foreground">
|
||||
{formatRelative(item.updatedAt)}
|
||||
<span className={cn('ms-auto shrink-0 text-[11px] text-muted-foreground', active && 'text-primary-600')}>
|
||||
{formatRelative(item.lastMessage?.createdAt || item.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
<p className={cn('mt-0.5 truncate text-xs text-muted-foreground', active && 'text-slate-700')}>
|
||||
{item.lastMessage?.sender === 'agent' ? '我:' : ''}
|
||||
{item.lastMessage?.translatedText ?? item.lastMessage?.originalText ?? item.summary}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<Badge className={cn('text-[10px] px-1 py-0', intent.color)}>{intent.label}</Badge>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{!active && <Badge className={cn('rounded-md px-1.5 py-0 text-[10px]', intent.color)}>{intent.label}</Badge>}
|
||||
<SentimentBadge sentiment={item.sentiment} emojiOnly />
|
||||
{item.assigneeId ? (
|
||||
<span className="text-[10px] text-muted-foreground">负责人:{memberName(item.assigneeId)}</span>
|
||||
<span className={cn('text-[10px] text-muted-foreground', active && 'text-slate-500')}>负责人:{memberName(item.assigneeId)}</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-amber-600">未分配</span>
|
||||
<span className="text-[10px] text-amber-700">未分配</span>
|
||||
)}
|
||||
{item.unreadCount > 0 && (
|
||||
<span className="ms-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-white">
|
||||
<span className={cn(
|
||||
'ms-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold',
|
||||
'bg-primary text-white',
|
||||
)}>
|
||||
{item.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateCustomer } from '@/services/api/customers'
|
||||
import { createTask } from '@/services/api/crm'
|
||||
import { analyzeConversation } from '@/services/api/ai'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import type { Customer, Conversation } from '@/types'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -29,6 +31,8 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
|
||||
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
||||
|
||||
export function InsightPanel({
|
||||
customer,
|
||||
@@ -38,6 +42,7 @@ export function InsightPanel({
|
||||
conversation: Conversation
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
|
||||
const c = countryMeta(customer.country)
|
||||
@@ -54,6 +59,25 @@ export function InsightPanel({
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
})
|
||||
const taskMut = useMutation({
|
||||
mutationFn: (title: string) => createTask({
|
||||
title,
|
||||
type: 'revisit',
|
||||
priority: customer.riskLevel === 'urgent' || customer.riskLevel === 'high' ? 'high' : 'medium',
|
||||
status: 'pending',
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
conversationId: conversation.id,
|
||||
ownerId: user?.id || '',
|
||||
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
note: `由 AI 客户洞察建议创建:${title}`,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
toast.success('已创建跟进任务', '默认明天到期')
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
})
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true)
|
||||
@@ -67,9 +91,9 @@ export function InsightPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-slate-50/40 scrollbar-thin">
|
||||
{/* 客户基本信息 */}
|
||||
<div className="border-b border-border p-4">
|
||||
<div className="border-b border-slate-100 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<Avatar name={customer.name} src={customer.avatarUrl} size="lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -120,7 +144,7 @@ export function InsightPanel({
|
||||
<Badge key={p} variant="primary">{p}</Badge>
|
||||
))}
|
||||
{customer.tags?.map((t) => (
|
||||
<Badge key={t} variant="outline">{t}</Badge>
|
||||
<CustomerSemanticTag key={t} tag={t} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -151,9 +175,14 @@ export function InsightPanel({
|
||||
</div>
|
||||
|
||||
{/* AI 分析 */}
|
||||
<div className="border-b border-border p-4">
|
||||
<div className="space-y-4 border-b border-slate-100 p-3">
|
||||
<CustomerProfileRadar customer={customer} compact />
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">AI 分析摘要</h4>
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight text-slate-900">AI 洞察摘要</p>
|
||||
<p className="text-[11px] text-slate-400">基于完整对话实时更新</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
|
||||
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
|
||||
</Button>
|
||||
@@ -164,41 +193,24 @@ export function InsightPanel({
|
||||
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{analysis}</p>
|
||||
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || 'AI 正在根据会话完善客户画像。'}</p>
|
||||
)}
|
||||
{/* AI 提取关键信息 */}
|
||||
{Object.values(customer.aiProfile || {}).some(Boolean) && (
|
||||
<div className="mt-3 space-y-1 border-t border-border pt-3">
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
|
||||
{Object.entries({
|
||||
公司: customer.aiProfile.companyName,
|
||||
行业: customer.aiProfile.industry,
|
||||
商业模式: customer.aiProfile.businessModel,
|
||||
采购角色: customer.aiProfile.purchasingRole,
|
||||
决策权: customer.aiProfile.decisionPower,
|
||||
询价数量: customer.aiProfile.inquiryQuantity,
|
||||
目标价格: customer.aiProfile.targetPrice,
|
||||
预算: customer.aiProfile.budget,
|
||||
MOQ: customer.aiProfile.moqRequirement,
|
||||
样品: customer.aiProfile.sampleNeed,
|
||||
定制: customer.aiProfile.customRequirement,
|
||||
交付: customer.aiProfile.deliveryTime,
|
||||
紧迫程度: customer.aiProfile.urgency,
|
||||
采购周期: customer.aiProfile.purchaseTimeline,
|
||||
物流: customer.aiProfile.logistics,
|
||||
付款: customer.aiProfile.payment,
|
||||
核心需求: customer.aiProfile.keyNeeds?.join('、'),
|
||||
痛点: customer.aiProfile.painPoints?.join('、'),
|
||||
顾虑: customer.aiProfile.concerns?.join('、'),
|
||||
异议: customer.aiProfile.objections?.join('、'),
|
||||
购买信号: customer.aiProfile.buyingSignals?.join('、'),
|
||||
竞品: customer.aiProfile.competitorMentions?.join('、'),
|
||||
沟通风格: customer.aiProfile.communicationStyle,
|
||||
建议语气: customer.aiProfile.recommendedTone,
|
||||
跟进时机: customer.aiProfile.followUpTiming,
|
||||
}).filter(([, v]) => v).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2 text-xs">
|
||||
<span className="shrink-0 text-muted-foreground">{k}</span>
|
||||
<span className="text-foreground">{v as string}</span>
|
||||
<div key={k} className="min-w-0 rounded-xl bg-slate-50 px-3 py-2">
|
||||
<p className="text-[10px] font-medium text-slate-400">{k}</p>
|
||||
<p className="mt-0.5 truncate text-xs font-medium text-slate-700">{v as string}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -209,7 +221,7 @@ export function InsightPanel({
|
||||
['流失概率', customer.aiProfile.churnProbability],
|
||||
['分析可信度', customer.aiProfile.confidenceScore],
|
||||
].map(([label, value]) => (
|
||||
<div key={String(label)} className="rounded-lg bg-muted/70 px-2 py-2 text-center">
|
||||
<div key={String(label)} className="rounded-xl bg-slate-50 px-2 py-2 text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-semibold">{Number(value || 0)}%</p>
|
||||
</div>
|
||||
@@ -223,17 +235,19 @@ export function InsightPanel({
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 推荐动作 */}
|
||||
<div className="p-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">AI 推荐下一步</h4>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<h4 className="text-xs font-semibold text-slate-500">AI 推荐下一步</h4>
|
||||
<div className="mt-2 space-y-2">
|
||||
{customer.aiProfile.nextActions.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => toast.info(`建议操作:${a}`)}
|
||||
className="rounded-full border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs text-primary-700 hover:bg-primary-100"
|
||||
onClick={() => taskMut.mutate(a)}
|
||||
disabled={taskMut.isPending}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-start text-xs text-slate-700 hover:border-primary-200 hover:bg-primary-50"
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
BookOpen,
|
||||
MoreHorizontal,
|
||||
Languages,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
@@ -31,6 +32,48 @@ import { getMessageAttachment } from '@/services/api/conversations'
|
||||
|
||||
export type ViewMode = 'original' | 'translated' | 'bilingual'
|
||||
|
||||
function EmailAttachmentLink({
|
||||
conversationId,
|
||||
messageId,
|
||||
attachment,
|
||||
}: {
|
||||
conversationId: string
|
||||
messageId: string
|
||||
attachment: NonNullable<Message['emailAttachments']>[number]
|
||||
}) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['email-attachment', conversationId, messageId, attachment.id],
|
||||
queryFn: () => getMessageAttachment(conversationId, messageId, attachment.id),
|
||||
enabled: attachment.available,
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
})
|
||||
const size = attachment.size >= 1024 * 1024
|
||||
? `${(attachment.size / 1024 / 1024).toFixed(1)} MB`
|
||||
: `${Math.max(1, Math.round(attachment.size / 1024))} KB`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={data?.dataUrl}
|
||||
download={data?.name || attachment.name}
|
||||
onClick={(event) => {
|
||||
if (!data?.dataUrl) event.preventDefault()
|
||||
}}
|
||||
className={cn(
|
||||
'mt-2 flex items-center gap-2 rounded-xl border border-slate-200/80 bg-white/75 px-3 py-2 transition-colors',
|
||||
data?.dataUrl && 'hover:border-sky-200 hover:bg-sky-50',
|
||||
)}
|
||||
>
|
||||
{isLoading ? <LoaderCircle className="h-5 w-5 animate-spin text-sky-500" /> : <FileText className="h-5 w-5 text-sky-500" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-slate-700">{attachment.name}</span>
|
||||
<span className="text-[10px] text-slate-400">{error ? '附件暂不可用' : size}</span>
|
||||
</span>
|
||||
{data?.dataUrl && <Download className="h-4 w-4 text-slate-400" />}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
msg,
|
||||
viewMode,
|
||||
@@ -62,7 +105,16 @@ export function MessageBubble({
|
||||
rawTranslation.length > (msg.originalText.length <= 20 ? 160 : Math.max(500, msg.originalText.length * 8))
|
||||
const translatedText = translationCorrupted ? '' : rawTranslation
|
||||
const hasTranslation = translatedText && translatedText !== msg.originalText
|
||||
const outgoingChineseNote = isMe
|
||||
? String(
|
||||
msg.translationCn ||
|
||||
(hasTranslation && /[\u3400-\u9fff]/u.test(msg.originalText) ? msg.originalText : ''),
|
||||
).trim()
|
||||
: ''
|
||||
const outgoingSentText = isMe && translatedText ? translatedText : msg.originalText
|
||||
const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6
|
||||
const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang))
|
||||
const sourceLanguage = hasKnownLanguage ? languageName(msg.originalLang) : '原文'
|
||||
const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({
|
||||
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId],
|
||||
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
|
||||
@@ -104,6 +156,20 @@ export function MessageBubble({
|
||||
)
|
||||
|
||||
const renderContent = () => {
|
||||
if (msg.type !== 'text') {
|
||||
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
|
||||
return msg.originalText ? (
|
||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{msg.originalText}</p>
|
||||
) : (
|
||||
<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" />
|
||||
{labels[msg.type]}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!String(msg.originalText || '').trim()) {
|
||||
return <p className="text-xs text-slate-500">Telegram 服务通知</p>
|
||||
}
|
||||
if (
|
||||
(viewMode === 'bilingual' || viewMode === 'translated') &&
|
||||
msg.sender === 'customer' &&
|
||||
@@ -124,12 +190,54 @@ export function MessageBubble({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isMe && viewMode === 'bilingual') {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100/80 px-2 py-0.5 text-[10px] font-medium text-blue-700">
|
||||
<Globe className="h-3 w-3" />
|
||||
客户收到 · {translatedText ? '英文译文' : '原文'}
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
|
||||
</div>
|
||||
{outgoingChineseNote ? (
|
||||
<div data-testid="outgoing-chinese-note" className="border-t border-blue-200/60 pt-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
<Languages className="h-3 w-3 text-primary" />
|
||||
中文备注 · 仅内部可见
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
|
||||
{outgoingChineseNote}
|
||||
</p>
|
||||
</div>
|
||||
) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? (
|
||||
<div className="flex items-center gap-1.5 border-t border-blue-200/60 pt-2 text-xs text-slate-500">
|
||||
<Languages className="h-3.5 w-3.5 text-primary" />
|
||||
{msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) {
|
||||
return (
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
<Languages className="h-3 w-3 text-primary" />
|
||||
中文备注 · 仅内部可见
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
|
||||
{outgoingChineseNote}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (viewMode === 'bilingual' && hasTranslation) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
原文 · {languageName(msg.originalLang)}
|
||||
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
|
||||
</div>
|
||||
@@ -148,7 +256,7 @@ export function MessageBubble({
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
<Languages className="h-3 w-3" />
|
||||
{isMe ? `${languageName(msg.originalLang)} → 英文发送内容` : `${languageName(msg.originalLang)} → 中文`}
|
||||
{isMe ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`}
|
||||
</span>
|
||||
<div className="mt-1.5">{translationContent(false)}</div>
|
||||
</div>
|
||||
@@ -156,10 +264,14 @@ export function MessageBubble({
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{languageName(msg.originalLang)}
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
|
||||
{hasKnownLanguage && (
|
||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{sourceLanguage}
|
||||
</span>
|
||||
)}
|
||||
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
|
||||
{msg.originalText}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -195,7 +307,7 @@ export function MessageBubble({
|
||||
data-testid="message-bubble"
|
||||
data-sender={msg.sender}
|
||||
className={cn(
|
||||
'group flex gap-2 px-4 py-2.5 will-change-transform',
|
||||
'group flex gap-2 px-3 py-2 will-change-transform sm:px-4 sm:py-2.5',
|
||||
isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
|
||||
)}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
@@ -217,11 +329,13 @@ export function MessageBubble({
|
||||
)}
|
||||
{/* 消息气泡 */}
|
||||
<div
|
||||
data-testid="message-surface"
|
||||
data-direction={isMe ? 'outgoing' : 'incoming'}
|
||||
className={cn(
|
||||
'relative rounded-2xl px-3.5 py-3 shadow-sm',
|
||||
'relative isolate rounded-2xl px-3.5 py-2.5 transition-shadow',
|
||||
isMe
|
||||
? 'rounded-br-md border border-primary/15 bg-gradient-to-br from-primary-50 to-emerald-50/80'
|
||||
: 'rounded-bl-md border border-border/90 bg-card',
|
||||
? 'telegram-bubble-out rounded-br-[5px]'
|
||||
: 'telegram-bubble-in rounded-bl-[5px]',
|
||||
msg.isImportant && 'ring-1 ring-amber-400',
|
||||
)}
|
||||
>
|
||||
@@ -267,6 +381,14 @@ export function MessageBubble({
|
||||
{msg.telegramFileId && attachmentError && (
|
||||
<p className="mt-2 rounded-lg bg-red-50 px-2.5 py-1.5 text-xs text-red-600">文件暂时无法下载,请稍后重试</p>
|
||||
)}
|
||||
{msg.emailAttachments?.map((emailAttachment) => (
|
||||
<EmailAttachmentLink
|
||||
key={emailAttachment.id}
|
||||
conversationId={msg.conversationId}
|
||||
messageId={msg.id}
|
||||
attachment={emailAttachment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底栏:时间 + 状态 */}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function AppLayout() {
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto scrollbar-thin" id="main-scroll">
|
||||
<main className="flex-1 overflow-y-auto bg-white/35 scrollbar-thin" id="main-scroll">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,9 @@ export function GlobalRealtime() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
// Inbox owns the richer message socket so it can update message and
|
||||
// conversation caches atomically. Keep only one live socket per page.
|
||||
if (!token || location.pathname.startsWith('/inbox')) return
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
let socket: WebSocket | undefined
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -34,12 +36,12 @@ export function GlobalRealtime() {
|
||||
socket.onmessage = (raw) => {
|
||||
let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string }
|
||||
try { event = JSON.parse(String(raw.data)) } catch { return }
|
||||
if (event.type !== 'message.created' || event.message?.sender !== 'customer') return
|
||||
if (!['message.created', 'message.received'].includes(event.type || '') || event.message?.sender !== 'customer') return
|
||||
void queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['conversations'] })
|
||||
if (!location.pathname.startsWith('/inbox')) {
|
||||
const preview = event.message.translatedText || event.message.originalText || `[${event.message.type}]`
|
||||
toast.info(`${event.customer?.name || event.message.senderName || 'Telegram 客户'} 发来新消息`, preview.slice(0, 120))
|
||||
toast.info(`${event.customer?.name || event.message.senderName || '客户'} 发来新消息`, preview.slice(0, 120))
|
||||
}
|
||||
}
|
||||
socket.onerror = () => socket?.close()
|
||||
|
||||
@@ -26,33 +26,49 @@ export function Sidebar() {
|
||||
const usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0
|
||||
|
||||
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
||||
const groups = [
|
||||
{ key: 'core', label: '核心业务' },
|
||||
{ key: 'intelligence', label: '知识与洞察' },
|
||||
{ key: 'admin', label: '企业管理' },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex h-full flex-col border-e border-border bg-card transition-[width] duration-200',
|
||||
sidebarCollapsed ? 'w-16' : 'w-60',
|
||||
'relative z-30 flex h-full flex-col border-e border-slate-300/80 bg-[#f8fafc] shadow-[4px_0_16px_-14px_rgba(15,23,42,.32)] transition-[width] duration-200',
|
||||
sidebarCollapsed ? 'w-[68px]' : 'w-[228px]',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="flex h-14 cursor-pointer items-center gap-2.5 border-b border-border px-4"
|
||||
className="flex h-16 cursor-pointer items-center gap-2.5 px-4"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary-600 to-primary-800 text-primary-foreground shadow-sm shadow-primary-900/10">
|
||||
<Sparkles className="h-4.5 w-4.5 h-[18px] w-[18px]" />
|
||||
</span>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold leading-tight">{t('app.name')}</p>
|
||||
<p className="truncate text-[15px] font-semibold tracking-tight leading-tight">{t('app.name')}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground leading-tight">{t('app.slogan')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-3 scrollbar-thin">
|
||||
{items.map((item) => (
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-2 scrollbar-thin">
|
||||
{groups.map((group) => {
|
||||
const groupItems = items.filter((item) => item.group === group.key)
|
||||
if (!groupItems.length) return null
|
||||
return (
|
||||
<div key={group.key} className="mb-3">
|
||||
{!sidebarCollapsed && (
|
||||
<p className="mb-1.5 px-3 pt-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{groupItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
@@ -60,10 +76,10 @@ export function Sidebar() {
|
||||
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-[13px] transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-primary-50 font-medium text-primary-700'
|
||||
: 'text-secondary-foreground hover:bg-muted',
|
||||
? 'bg-primary-50/90 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.08)]'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)
|
||||
}
|
||||
@@ -71,7 +87,11 @@ export function Sidebar() {
|
||||
<item.icon className="h-[18px] w-[18px] shrink-0" />
|
||||
{!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 底部:套餐与企业信息 */}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { cn } from '@/utils/cn'
|
||||
|
||||
const NOTIF_TYPE_COLOR: Record<string, string> = {
|
||||
inquiry: 'bg-emerald-100 text-emerald-700',
|
||||
message: 'bg-sky-100 text-sky-700',
|
||||
risk: 'bg-red-100 text-red-700',
|
||||
task: 'bg-amber-100 text-amber-700',
|
||||
channel: 'bg-blue-100 text-blue-700',
|
||||
@@ -61,7 +62,7 @@ export function Topbar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-3 md:px-4">
|
||||
<header className="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200/70 bg-white/90 px-3 backdrop-blur-xl md:px-5">
|
||||
{/* 移动端菜单 */}
|
||||
<button
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
|
||||
@@ -155,7 +156,7 @@ export function Topbar() {
|
||||
label: (
|
||||
<span className="flex flex-col gap-0.5 py-0.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
|
||||
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'message' ? '消息' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
|
||||
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-red-500" />}
|
||||
<span className="text-[11px] text-muted-foreground">{formatRelative(n.createdAt)}</span>
|
||||
</span>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/utils/cn'
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-lg border border-border bg-card shadow-[0_1px_2px_rgba(16,24,40,0.04)]', className)}
|
||||
className={cn('rounded-2xl border border-slate-200/80 bg-card shadow-[0_1px_2px_rgba(15,23,42,0.035),0_10px_30px_rgba(15,23,42,0.025)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -19,9 +19,9 @@ export function CardHeader({
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { title?: ReactNode; description?: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)} {...props}>
|
||||
<div className={cn('flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4', className)} {...props}>
|
||||
<div className="min-w-0">
|
||||
{title && <h3 className="text-sm font-semibold text-foreground">{title}</h3>}
|
||||
{title && <h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>}
|
||||
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Dropdown({
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-40 mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
|
||||
'absolute z-[80] mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
|
||||
align === 'end' ? 'right-0 rtl:right-auto rtl:left-0' : 'left-0 rtl:left-auto rtl:right-0',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -57,23 +57,35 @@ export function Segmented<T extends string>({
|
||||
options,
|
||||
size = 'default',
|
||||
className,
|
||||
ariaLabel = '切换选项',
|
||||
}: {
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
options: { value: T; label: ReactNode }[]
|
||||
size?: 'sm' | 'default'
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center rounded-md bg-muted p-0.5', className)}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn('inline-flex items-center rounded-lg border border-slate-200 bg-slate-100/90 p-0.5 shadow-inner', className)}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={o.value === value}
|
||||
data-state={o.value === value ? 'active' : 'inactive'}
|
||||
key={o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'rounded px-2.5 text-xs font-medium transition-colors',
|
||||
'relative rounded-md px-2.5 text-xs font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
|
||||
size === 'sm' ? 'h-6' : 'h-7',
|
||||
o.value === value ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
o.value === value
|
||||
? 'bg-primary text-white shadow-sm shadow-primary/20'
|
||||
: 'text-slate-500 hover:bg-white/70 hover:text-slate-900',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
|
||||
+11
-16
@@ -3,15 +3,12 @@ import {
|
||||
MessagesSquare,
|
||||
Users,
|
||||
KanbanSquare,
|
||||
Languages,
|
||||
ShieldAlert,
|
||||
BookOpen,
|
||||
BarChart3,
|
||||
Cable,
|
||||
UsersRound,
|
||||
CreditCard,
|
||||
Settings,
|
||||
GraduationCap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { Permission } from './roles'
|
||||
@@ -23,20 +20,18 @@ export interface NavItem {
|
||||
labelKey: string
|
||||
icon: LucideIcon
|
||||
permission?: Permission
|
||||
group: 'core' | 'intelligence' | 'admin'
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard },
|
||||
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare },
|
||||
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view' },
|
||||
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare },
|
||||
{ key: 'translate', path: '/translate', labelKey: 'nav.translate', icon: Languages },
|
||||
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert },
|
||||
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen },
|
||||
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view' },
|
||||
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable },
|
||||
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound },
|
||||
{ key: 'billing', path: '/billing', labelKey: 'nav.billing', icon: CreditCard },
|
||||
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings },
|
||||
{ key: 'help', path: '/help', labelKey: 'nav.help', icon: GraduationCap },
|
||||
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, group: 'core' },
|
||||
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare, group: 'core' },
|
||||
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view', group: 'core' },
|
||||
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare, permission: 'opportunity:manage', group: 'core' },
|
||||
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert, permission: 'risk:handle', group: 'core' },
|
||||
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen, permission: 'knowledge:manage', group: 'intelligence' },
|
||||
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view', group: 'intelligence' },
|
||||
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable, permission: 'channel:manage', group: 'admin' },
|
||||
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound, permission: 'team:manage', group: 'admin' },
|
||||
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings, permission: 'settings:manage', group: 'admin' },
|
||||
]
|
||||
|
||||
@@ -6,7 +6,7 @@ export const en: LocaleDict = {
|
||||
'app.company': 'Xuzhou Ouhui Technology Co., Ltd.',
|
||||
|
||||
'nav.dashboard': 'Workspace',
|
||||
'nav.inbox': 'Unified Inbox',
|
||||
'nav.inbox': 'Messages',
|
||||
'nav.customers': 'Customers',
|
||||
'nav.opportunities': 'Deals & Follow-ups',
|
||||
'nav.translate': 'AI Translation',
|
||||
|
||||
@@ -4,15 +4,15 @@ export const zh = {
|
||||
'app.company': '徐州欧汇科技有限公司',
|
||||
|
||||
'nav.dashboard': '工作台',
|
||||
'nav.inbox': '统一消息',
|
||||
'nav.inbox': '消息中心',
|
||||
'nav.customers': '客户管理',
|
||||
'nav.opportunities': '商机与跟进',
|
||||
'nav.translate': 'AI翻译',
|
||||
'nav.interpret': '同声传译',
|
||||
'nav.risks': '风险预警',
|
||||
'nav.risks': '风险工单',
|
||||
'nav.knowledge': '企业知识库',
|
||||
'nav.analytics': '数据分析',
|
||||
'nav.channels': '渠道中心',
|
||||
'nav.channels': '渠道与账号',
|
||||
'nav.team': '团队协作',
|
||||
'nav.billing': '套餐与账单',
|
||||
'nav.settings': '系统设置',
|
||||
|
||||
@@ -46,7 +46,7 @@ export function AnalyticsPage() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="客户增长趋势" height={260}><TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#247b71' }]} /></ChartCard>
|
||||
<ChartCard title="客户增长趋势" height={260}><TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#2563eb' }]} /></ChartCard>
|
||||
<ChartCard title="各渠道询盘数量" height={260}><SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" /></ChartCard>
|
||||
<ChartCard title="客户国家/地区分布" height={260}><DonutChart data={data.countryDistribution} /></ChartCard>
|
||||
<ChartCard title="客户阶段转化漏斗" height={260}><FunnelChart data={data.funnel} /></ChartCard>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { INDUSTRIES } from '@/config/constants'
|
||||
const schema = z.object({
|
||||
name: z.string().min(2, '请输入您的姓名'),
|
||||
account: z.string().min(5, '请输入手机号或邮箱'),
|
||||
password: z.string().min(6, '密码至少6位').max(32, '密码最长32位'),
|
||||
password: z.string().min(8, '密码至少8位').max(32, '密码最长32位'),
|
||||
company: z.string().min(2, '请输入企业名称'),
|
||||
industry: z.string().min(1, '请选择所属行业'),
|
||||
teamSize: z.string().min(1, '请选择团队规模'),
|
||||
@@ -51,7 +51,7 @@ export function RegisterPage() {
|
||||
const session = await registerApi(values)
|
||||
setSession(session)
|
||||
toast.success('企业创建成功', '接下来请完成企业初始化配置')
|
||||
navigate('/channels')
|
||||
navigate('/channels?setup=telegram')
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e))
|
||||
} finally {
|
||||
@@ -79,7 +79,7 @@ export function RegisterPage() {
|
||||
<Field label="手机号或邮箱" required error={errors.account?.message}>
|
||||
<Input placeholder="用于登录与接收通知" {...register('account')} />
|
||||
</Field>
|
||||
<Field label="设置密码" required error={errors.password?.message} hint="6—32位,建议包含字母与数字">
|
||||
<Field label="设置密码" required error={errors.password?.message} hint="8—32位,建议包含字母与数字">
|
||||
<Input type="password" placeholder="请设置登录密码" {...register('password')} />
|
||||
</Field>
|
||||
<Field label="企业名称" required error={errors.company?.message}>
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Cable, Plus, Pause, Play, RefreshCw, Trash2, History, Settings } from 'lucide-react'
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
Cable,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Mail,
|
||||
MessageSquareText,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Smartphone,
|
||||
Trash2,
|
||||
Workflow,
|
||||
} from 'lucide-react'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { confirmDialog } from '@/app/store/confirm'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
@@ -15,12 +32,12 @@ import { Input, Field } from '@/components/ui/input'
|
||||
import { PermissionGate } from '@/components/common/PermissionGate'
|
||||
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
||||
import {
|
||||
getChannels, connectChannel, testChannel, updateChannel, deleteChannel, getChannelLogs,
|
||||
startTelegramLogin, submitTelegramCode, submitTelegramPassword, syncTelegramChannel,
|
||||
getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs,
|
||||
connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword,
|
||||
getTelegramLoginState, syncTelegramChannel,
|
||||
} from '@/services/api/channels'
|
||||
import { CHANNEL_META } from '@/config/constants'
|
||||
import { formatRelative } from '@/utils/format'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
@@ -31,7 +48,17 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
unconnected: { label: '未连接', color: 'bg-gray-100 text-gray-400' },
|
||||
}
|
||||
|
||||
const EMAIL_PRESETS = {
|
||||
gmail: { label: 'Gmail', imapHost: 'imap.gmail.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true },
|
||||
outlook: { label: 'Outlook / Microsoft 365', imapHost: 'outlook.office365.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.office365.com', smtpPort: 587, smtpSecure: false },
|
||||
qq: { label: 'QQ 企业邮箱', imapHost: 'imap.exmail.qq.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.exmail.qq.com', smtpPort: 465, smtpSecure: true },
|
||||
netease: { label: '网易企业邮箱', imapHost: 'imap.qiye.163.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.qiye.163.com', smtpPort: 465, smtpSecure: true },
|
||||
custom: { label: '自定义服务器', imapHost: '', imapPort: 993, imapSecure: true, smtpHost: '', smtpPort: 465, smtpSecure: true },
|
||||
} as const
|
||||
|
||||
export function ChannelsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: getChannels })
|
||||
const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
|
||||
@@ -40,9 +67,25 @@ export function ChannelsPage() {
|
||||
const [telegramPassword, setTelegramPassword] = useState('')
|
||||
const [loginChannelId, setLoginChannelId] = useState('')
|
||||
const [authState, setAuthState] = useState('')
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [emailPreset, setEmailPreset] = useState<keyof typeof EMAIL_PRESETS>('custom')
|
||||
const [emailForm, setEmailForm] = useState({
|
||||
displayName: '',
|
||||
account: '',
|
||||
password: '',
|
||||
imapHost: '',
|
||||
imapPort: 993,
|
||||
imapSecure: true,
|
||||
imapFolder: 'INBOX',
|
||||
smtpHost: '',
|
||||
smtpPort: 465,
|
||||
smtpSecure: true,
|
||||
syncDays: 30,
|
||||
})
|
||||
const [logDrawer, setLogDrawer] = useState<string | null>(null)
|
||||
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
|
||||
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
|
||||
const setupOpenedRef = useRef(false)
|
||||
|
||||
const loadLogs = async (chId: string) => {
|
||||
const logs = await getChannelLogs(chId)
|
||||
@@ -58,116 +101,564 @@ export function ChannelsPage() {
|
||||
|
||||
const list = channels ?? []
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<PageHeader title="渠道中心" description={`${list.filter((c) => c.status === 'connected').length} 个渠道已连接 · 共 ${list.length} 个可连接渠道`} />
|
||||
const resetTelegramWizard = () => {
|
||||
setTelegramAccount('')
|
||||
setTelegramCode('')
|
||||
setTelegramPassword('')
|
||||
setLoginChannelId('')
|
||||
setAuthState('')
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
const setTelegramStepFromState = (state: string) => {
|
||||
if (state === 'waiting_password') {
|
||||
setAuthState('waiting_password')
|
||||
setWizard({ open: true, type: 'telegram', step: 2 })
|
||||
return
|
||||
}
|
||||
setAuthState('waiting_code')
|
||||
setWizard({ open: true, type: 'telegram', step: 1 })
|
||||
}
|
||||
|
||||
const completeTelegramBinding = async (channelId: string) => {
|
||||
let syncSummary = ''
|
||||
try {
|
||||
const result = await syncTelegramChannel(channelId)
|
||||
syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息`
|
||||
} catch (error) {
|
||||
syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}`
|
||||
}
|
||||
toast.success(`Telegram 绑定成功${syncSummary}`)
|
||||
resetTelegramWizard()
|
||||
setWizard({ open: false, type: null, step: 0 })
|
||||
await queryClient.invalidateQueries()
|
||||
}
|
||||
|
||||
const openTelegramWizard = async (channel?: ChannelConnection) => {
|
||||
resetTelegramWizard()
|
||||
setTelegramAccount(channel?.account || '')
|
||||
setWizard({ open: true, type: 'telegram', step: 0 })
|
||||
if (!channel || channel.id === 'telegram-available') return
|
||||
setConnecting(true)
|
||||
try {
|
||||
const result = await getTelegramLoginState(channel.id)
|
||||
setLoginChannelId(channel.id)
|
||||
if (result.status === 'ready' && result.connectionState === 'ready') {
|
||||
await completeTelegramBinding(channel.id)
|
||||
return
|
||||
}
|
||||
setTelegramStepFromState(result.status)
|
||||
toast.info(result.status === 'waiting_password' ? '请继续输入二步验证密码' : '请继续输入 Telegram 验证码')
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!channels || setupOpenedRef.current || searchParams.get('setup') !== 'telegram') return
|
||||
setupOpenedRef.current = true
|
||||
const telegram = channels.find((item) => item.type === 'telegram')
|
||||
void openTelegramWizard(telegram)
|
||||
setSearchParams({}, { replace: true })
|
||||
// 新注册后的 setup 参数只消费一次,避免查询刷新时重复打开向导。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [channels, searchParams, setSearchParams])
|
||||
|
||||
const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => {
|
||||
setEmailPreset(value)
|
||||
const preset = EMAIL_PRESETS[value]
|
||||
setEmailForm((current) => ({
|
||||
...current,
|
||||
imapHost: preset.imapHost,
|
||||
imapPort: preset.imapPort,
|
||||
imapSecure: preset.imapSecure,
|
||||
smtpHost: preset.smtpHost,
|
||||
smtpPort: preset.smtpPort,
|
||||
smtpSecure: preset.smtpSecure,
|
||||
}))
|
||||
}
|
||||
|
||||
const connectedCount = list.filter((channel) => channel.status === 'connected').length
|
||||
const telegramChannel = list.find((channel) => channel.type === 'telegram')
|
||||
const telegramConnected = telegramChannel?.status === 'connected'
|
||||
const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0)
|
||||
const latestSync = list
|
||||
.map((channel) => channel.lastSyncAt)
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.at(-1)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1480px] p-4 md:p-7">
|
||||
<section className="relative mb-6 overflow-hidden rounded-[28px] border border-slate-200/80 bg-gradient-to-br from-white via-sky-50/40 to-emerald-50/60 px-5 py-6 shadow-[0_18px_60px_rgba(15,23,42,.06)] md:px-7">
|
||||
<div className="pointer-events-none absolute -right-20 -top-24 h-64 w-64 rounded-full bg-sky-200/25 blur-3xl" />
|
||||
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-primary-200/80 bg-white/85 px-3 py-1 text-[11px] font-semibold text-primary-700 shadow-sm">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-400 opacity-45" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
|
||||
</span>
|
||||
Unified communication hub
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-[-0.035em] text-slate-950 md:text-3xl">渠道与账号</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
将 Telegram 即时消息与企业邮箱汇入同一个客户工作台,统一翻译、画像、分配和跟进。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 sm:min-w-[460px]">
|
||||
{[
|
||||
{ label: '在线账号', value: `${connectedCount}/${list.length}`, icon: CheckCircle2 },
|
||||
{ label: '今日消息', value: String(todayMessages), icon: MessageSquareText },
|
||||
{ label: '最近同步', value: latestSync ? formatRelative(latestSync) : '等待首次同步', icon: Activity },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="rounded-2xl border border-white/80 bg-white/75 p-3 shadow-sm backdrop-blur">
|
||||
<stat.icon className="mb-2 h-4 w-4 text-primary" />
|
||||
<p className="truncate text-base font-bold tracking-tight text-slate-900">{stat.value}</p>
|
||||
<p className="mt-0.5 text-[10px] font-medium text-slate-400">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PageHeader
|
||||
title="通信账号"
|
||||
description="连接状态、同步进度与安全设置"
|
||||
/>
|
||||
|
||||
{!telegramConnected && (
|
||||
<Card
|
||||
data-testid="telegram-onboarding"
|
||||
className="mb-5 overflow-hidden border-primary-200/80 bg-gradient-to-r from-primary-50/90 via-white to-sky-50 shadow-[0_16px_45px_rgba(37,99,235,.10)]"
|
||||
>
|
||||
<div className="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:p-6">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="primary">首次配置 · 约 2 分钟</Badge>
|
||||
<span className="text-xs font-medium text-slate-500">使用你自己的 Telegram 工作账号</span>
|
||||
</div>
|
||||
<h2 className="mt-3 text-lg font-bold tracking-tight text-slate-950">先绑定 Telegram,开始接收真实客户消息</h2>
|
||||
<p className="mt-1.5 max-w-3xl text-sm leading-6 text-slate-600">
|
||||
输入国际格式手机号,依次完成 Telegram 验证码和可选的二步密码。授权成功后系统会自动注册实时 Webhook,并首次同步历史会话。
|
||||
</p>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
{ icon: Smartphone, step: '01', title: '验证手机号', text: '例如 +8613800138000' },
|
||||
{ icon: ShieldCheck, step: '02', title: '完成安全验证', text: '验证码与二步密码' },
|
||||
{ icon: RefreshCw, step: '03', title: '自动同步消息', text: '历史会话与实时消息' },
|
||||
].map((item) => (
|
||||
<div key={item.step} className="flex items-center gap-3 rounded-xl border border-white bg-white/80 px-3 py-2.5 shadow-sm">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-50 text-primary">
|
||||
<item.icon className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-slate-800">{item.step} · {item.title}</p>
|
||||
<p className="mt-0.5 truncate text-[10px] text-slate-400">{item.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full shadow-lg shadow-primary/20 lg:w-auto"
|
||||
loading={connecting}
|
||||
onClick={() => void openTelegramWizard(telegramChannel)}
|
||||
>
|
||||
<Cable className="h-4 w-4" />
|
||||
{telegramChannel && telegramChannel.id !== 'telegram-available' ? '继续 Telegram 授权' : '开始绑定 Telegram'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((ch) => {
|
||||
const meta = CHANNEL_META[ch.type]
|
||||
const st = STATUS_MAP[ch.status]
|
||||
return (
|
||||
<Card key={ch.id} className="p-4">
|
||||
<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="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<ChannelIcon channel={ch.type} size="lg" withBg />
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-slate-100 transition-transform duration-300 group-hover:scale-105">
|
||||
<ChannelIcon channel={ch.type} size="lg" />
|
||||
</div>
|
||||
<Badge className={st.color}>{st.label}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium">{meta.name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{meta.desc}</p>
|
||||
{ch.account && <p className="mt-1 text-xs text-muted-foreground">账号:{ch.account}</p>}
|
||||
{ch.todayMessages > 0 && <p className="mt-1 text-xs">今日消息:{ch.todayMessages} 条</p>}
|
||||
{ch.lastSyncAt && <p className="mt-0.5 text-xs text-muted-foreground">最近同步:{formatRelative(ch.lastSyncAt)}</p>}
|
||||
<div className="mt-4">
|
||||
<p className="text-base font-semibold tracking-tight text-slate-950">{meta.name}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">{meta.desc}</p>
|
||||
{ch.account && <p className="mt-2 truncate text-xs font-medium text-slate-700">{ch.account}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
|
||||
<p className="text-[10px] font-medium text-slate-400">今日消息</p>
|
||||
<p className="mt-0.5 text-sm font-semibold text-slate-800">{Number(ch.todayMessages || 0)} 条</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
|
||||
<p className="text-[10px] font-medium text-slate-400">同步状态</p>
|
||||
<p className="mt-0.5 truncate text-sm font-semibold text-slate-800">{ch.lastSyncAt ? formatRelative(ch.lastSyncAt) : '等待同步'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{(ch.type === 'email'
|
||||
? ['IMAP 收信', 'SMTP 发信', '附件同步']
|
||||
: ['实时收发', '历史同步', '媒体消息']
|
||||
).map((capability) => (
|
||||
<span key={capability} className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-500">
|
||||
{capability}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{ch.errorMessage && <p className="mt-1 text-xs text-red-600">{ch.errorMessage}</p>}
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
<div className="mt-5 flex flex-wrap items-center gap-1 border-t border-slate-100 pt-4">
|
||||
{ch.status === 'connected' ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={async () => {
|
||||
const result = await syncTelegramChannel(ch.id)
|
||||
toast.success(`同步完成:${result.chats} 个会话,${result.messages} 条消息`)
|
||||
queryClient.invalidateQueries()
|
||||
}}><RefreshCw className="h-3 w-3" />同步</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'paused' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Pause className="h-3 w-3" />暂停</Button>
|
||||
try {
|
||||
const result = await syncTelegramChannel(ch.id)
|
||||
toast.success(`同步完成:${result.chats} 个会话,${result.messages} 条新消息`)
|
||||
queryClient.invalidateQueries()
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
}
|
||||
}}><RefreshCw className="h-3 w-3" />立即同步</Button>
|
||||
<Button size="sm" variant="outline" onClick={async () => {
|
||||
try {
|
||||
await updateChannel(ch.id, { status: 'paused' })
|
||||
toast.success('渠道已暂停,实时 Webhook 已停用')
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
}
|
||||
}}><Pause className="h-3 w-3" />暂停</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
|
||||
</>
|
||||
) : ch.status === 'paused' ? (
|
||||
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'connected' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Play className="h-3 w-3" />恢复</Button>
|
||||
<Button size="sm" variant="outline" onClick={async () => {
|
||||
try {
|
||||
await updateChannel(ch.id, { status: 'connected' })
|
||||
toast.success('渠道已恢复,实时 Webhook 已启用')
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
}
|
||||
}}><Play className="h-3 w-3" />恢复</Button>
|
||||
) : ch.status === 'error' ? (
|
||||
<Button size="sm" variant="outline" onClick={() => { setWizard({ open: true, type: ch.type, step: 0 }); toast.info('重新授权') }}><RefreshCw className="h-3 w-3" />重新授权</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (ch.type === 'email') {
|
||||
applyEmailPreset('custom')
|
||||
setWizard({ open: true, type: ch.type, step: 0 })
|
||||
} else {
|
||||
void openTelegramWizard(ch)
|
||||
}
|
||||
}}
|
||||
><RefreshCw className="h-3 w-3" />重新授权</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="default" onClick={() => setWizard({ open: true, type: ch.type, step: 1 })}><Cable className="h-3 w-3" />连接</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
if (ch.type === 'email') {
|
||||
applyEmailPreset('custom')
|
||||
setWizard({ open: true, type: ch.type, step: 0 })
|
||||
} else {
|
||||
void openTelegramWizard(ch)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{ch.type === 'email' ? <Mail className="h-3 w-3" /> : <Cable className="h-3 w-3" />}
|
||||
{ch.type === 'telegram' && ch.id !== 'telegram-available' ? '继续授权' : '连接'}
|
||||
</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>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
<Card className="relative overflow-hidden border-dashed border-slate-300 bg-slate-50/60 p-5">
|
||||
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-primary/5" />
|
||||
<div className="relative">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-white shadow-lg shadow-primary/20">
|
||||
<Workflow className="h-5 w-5" />
|
||||
</span>
|
||||
<h3 className="mt-4 text-base font-semibold text-slate-900">统一处理链路</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-500">无论消息来自 Telegram 还是 Email,都进入同一套真实业务流程。</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{[
|
||||
['01', '实时接收与自动同步'],
|
||||
['02', 'AI 翻译、情绪与意图识别'],
|
||||
['03', '客户画像、风险与任务联动'],
|
||||
].map(([step, label]) => (
|
||||
<div key={step} className="flex items-center gap-3 rounded-xl bg-white px-3 py-2.5 shadow-sm ring-1 ring-slate-100">
|
||||
<span className="text-[10px] font-bold text-primary">{step}</span>
|
||||
<span className="text-xs font-medium text-slate-700">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button className="mt-4 w-full" variant="outline" onClick={() => navigate('/inbox')}>
|
||||
进入消息中心 <ArrowUpRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 连接向导 */}
|
||||
<Dialog
|
||||
open={wizard.open}
|
||||
onOpenChange={(v) => setWizard({ ...wizard, open: v })}
|
||||
onOpenChange={(open) => {
|
||||
setWizard({ ...wizard, open })
|
||||
if (!open) {
|
||||
setEmailForm((current) => ({ ...current, password: '' }))
|
||||
resetTelegramWizard()
|
||||
}
|
||||
}}
|
||||
title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
|
||||
footer={
|
||||
<Button
|
||||
loading={connecting}
|
||||
onClick={async () => {
|
||||
if (!loginChannelId) {
|
||||
if (telegramAccount.trim()) {
|
||||
const channel = await startTelegramLogin(telegramAccount.trim())
|
||||
setConnecting(true)
|
||||
try {
|
||||
if (wizard.type === 'email') {
|
||||
await connectEmailChannel(emailForm)
|
||||
toast.success('企业邮箱连接成功,历史邮件已开始同步')
|
||||
setWizard({ open: false, type: null, step: 0 })
|
||||
setEmailForm((current) => ({ ...current, password: '' }))
|
||||
queryClient.invalidateQueries()
|
||||
return
|
||||
}
|
||||
|
||||
if (!loginChannelId) {
|
||||
const phone = telegramAccount.replace(/\s+/g, '')
|
||||
if (!/^\+\d{7,15}$/.test(phone)) {
|
||||
toast.warning('请输入国际格式手机号,例如 +8613800138000')
|
||||
return
|
||||
}
|
||||
const channel = await startTelegramLogin(phone)
|
||||
setTelegramAccount(phone)
|
||||
setLoginChannelId(channel.id)
|
||||
setAuthState('waiting_code')
|
||||
toast.success('验证码已发送到 Telegram')
|
||||
if (channel.status === 'connected' || channel.authState === 'ready') {
|
||||
await completeTelegramBinding(channel.id)
|
||||
return
|
||||
}
|
||||
setTelegramStepFromState(channel.authState || 'waiting_code')
|
||||
toast.success(channel.authState === 'waiting_password'
|
||||
? '请继续输入 Telegram 二步验证密码'
|
||||
: '验证码已发送到 Telegram')
|
||||
return
|
||||
}
|
||||
const channel = await connectChannel({ type: 'telegram', account: '现有网关账号', autoReply: false })
|
||||
toast.success(channel.status === 'connected' ? '现有 Telegram 账号已连接' : '账号尚未完成授权')
|
||||
setWizard({ open: false, type: null, step: 0 })
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
return
|
||||
}
|
||||
if (authState === 'waiting_code') {
|
||||
const result = await submitTelegramCode(loginChannelId, telegramCode)
|
||||
if (result.status === 'waiting_password') {
|
||||
setAuthState('waiting_password')
|
||||
toast.info('该账号启用了二步验证,请输入密码')
|
||||
return
|
||||
if (authState === 'waiting_code') {
|
||||
const code = telegramCode.replace(/\s+/g, '')
|
||||
if (!/^\d{4,8}$/.test(code)) {
|
||||
toast.warning('请输入 4—8 位 Telegram 验证码')
|
||||
return
|
||||
}
|
||||
const result = await submitTelegramCode(loginChannelId, code)
|
||||
if (result.status === 'waiting_password') {
|
||||
setTelegramStepFromState('waiting_password')
|
||||
toast.info('该账号启用了二步验证,请输入密码')
|
||||
return
|
||||
}
|
||||
if (result.status !== 'ready') {
|
||||
setTelegramStepFromState(result.status)
|
||||
toast.info('Telegram 仍在等待验证,请检查验证码后重试')
|
||||
return
|
||||
}
|
||||
} else if (authState === 'waiting_password') {
|
||||
if (!telegramPassword) {
|
||||
toast.warning('请输入 Telegram 二步验证密码')
|
||||
return
|
||||
}
|
||||
const result = await submitTelegramPassword(loginChannelId, telegramPassword)
|
||||
if (result.status !== 'ready') {
|
||||
setTelegramStepFromState(result.status)
|
||||
toast.info('Telegram 授权尚未完成,请重试')
|
||||
return
|
||||
}
|
||||
}
|
||||
} else if (authState === 'waiting_password') {
|
||||
await submitTelegramPassword(loginChannelId, telegramPassword)
|
||||
await completeTelegramBinding(loginChannelId)
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setConnecting(false)
|
||||
}
|
||||
toast.success('Telegram 授权完成')
|
||||
setWizard({ open: false, type: null, step: 0 })
|
||||
setLoginChannelId('')
|
||||
setAuthState('')
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
}}
|
||||
>{!loginChannelId ? '连接' : authState === 'waiting_password' ? '提交二步密码' : '提交验证码'}</Button>
|
||||
>
|
||||
{wizard.type === 'email'
|
||||
? '验证并连接邮箱'
|
||||
: !loginChannelId
|
||||
? '发送 Telegram 验证码'
|
||||
: authState === 'waiting_password'
|
||||
? '验证二步密码并完成绑定'
|
||||
: '验证并完成绑定'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="primary">步骤 {wizard.step + 1}/7</Badge>
|
||||
<span className="text-sm font-medium">连接现有 Telegram 网关账号</span>
|
||||
{wizard.type === 'email' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-sky-100 bg-sky-50/70 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-sky-900">
|
||||
<Mail className="h-4 w-4" />
|
||||
IMAP 收信 + SMTP 发信
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-sky-700">
|
||||
密码在服务端使用 AES-256-GCM 加密保存,不会返回浏览器。Gmail、Outlook 等通常需要应用专用密码。
|
||||
</p>
|
||||
</div>
|
||||
<Field label="邮箱服务商">
|
||||
<Select
|
||||
value={emailPreset}
|
||||
onChange={(event) => applyEmailPreset(event.target.value as keyof typeof EMAIL_PRESETS)}
|
||||
options={Object.entries(EMAIL_PRESETS).map(([value, preset]) => ({ value, label: preset.label }))}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="发件人名称">
|
||||
<Input
|
||||
value={emailForm.displayName}
|
||||
onChange={(event) => setEmailForm({ ...emailForm, displayName: event.target.value })}
|
||||
placeholder="例如:Lingxi Sales"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="邮箱地址">
|
||||
<Input
|
||||
type="email"
|
||||
value={emailForm.account}
|
||||
onChange={(event) => setEmailForm({ ...emailForm, account: event.target.value })}
|
||||
placeholder="sales@company.com"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="邮箱密码 / 应用专用密码">
|
||||
<Input
|
||||
type="password"
|
||||
value={emailForm.password}
|
||||
onChange={(event) => setEmailForm({ ...emailForm, password: event.target.value })}
|
||||
placeholder="仅提交到本地服务端验证"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-[1fr_96px] gap-3">
|
||||
<Field label="IMAP 服务器">
|
||||
<Input value={emailForm.imapHost} onChange={(event) => setEmailForm({ ...emailForm, imapHost: event.target.value })} placeholder="imap.company.com" />
|
||||
</Field>
|
||||
<Field label="端口">
|
||||
<Input type="number" value={emailForm.imapPort} onChange={(event) => setEmailForm({ ...emailForm, imapPort: Number(event.target.value) })} />
|
||||
</Field>
|
||||
<Field label="SMTP 服务器">
|
||||
<Input value={emailForm.smtpHost} onChange={(event) => setEmailForm({ ...emailForm, smtpHost: event.target.value })} placeholder="smtp.company.com" />
|
||||
</Field>
|
||||
<Field label="端口">
|
||||
<Input type="number" value={emailForm.smtpPort} onChange={(event) => setEmailForm({ ...emailForm, smtpPort: Number(event.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Field label="同步文件夹">
|
||||
<Input value={emailForm.imapFolder} onChange={(event) => setEmailForm({ ...emailForm, imapFolder: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="首次同步范围">
|
||||
<Select
|
||||
value={String(emailForm.syncDays)}
|
||||
onChange={(event) => setEmailForm({ ...emailForm, syncDays: Number(event.target.value) })}
|
||||
options={[7, 30, 90, 180, 365].map((days) => ({ value: String(days), label: `最近 ${days} 天` }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-5">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Switch checked={emailForm.imapSecure} onCheckedChange={(checked) => setEmailForm({ ...emailForm, imapSecure: checked })} />
|
||||
IMAP SSL/TLS
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Switch checked={emailForm.smtpSecure} onCheckedChange={(checked) => setEmailForm({ ...emailForm, smtpSecure: checked })} />
|
||||
SMTP 直连 SSL
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{!loginChannelId && (
|
||||
<Field label="Telegram 手机号(留空则连接服务器现有账号)">
|
||||
<Input value={telegramAccount} onChange={(e) => setTelegramAccount(e.target.value)} placeholder="+8613800138000" />
|
||||
</Field>
|
||||
)}
|
||||
{authState === 'waiting_code' && (
|
||||
<Field label="Telegram 验证码">
|
||||
<Input value={telegramCode} onChange={(e) => setTelegramCode(e.target.value)} placeholder="请输入验证码" />
|
||||
</Field>
|
||||
)}
|
||||
{authState === 'waiting_password' && (
|
||||
<Field label="Telegram 二步验证密码">
|
||||
<Input type="password" value={telegramPassword} onChange={(e) => setTelegramPassword(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">手机号、验证码和二步密码只发送到服务端 Telegram Gateway,不在浏览器持久化。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-primary-100 bg-primary-50/70 p-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-semibold text-primary-900">绑定你自己的 Telegram 工作账号</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-primary-700">
|
||||
每个企业独立绑定账号,不会共享其他租户的 Telegram 会话或凭据。
|
||||
</p>
|
||||
</div>
|
||||
<div aria-label="Telegram 绑定进度" className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ step: 0, label: '手机号' },
|
||||
{ step: 1, label: '验证码' },
|
||||
{ step: 2, label: '二步密码' },
|
||||
].map((item) => {
|
||||
const active = wizard.step === item.step
|
||||
const complete = wizard.step > item.step
|
||||
return (
|
||||
<div
|
||||
key={item.step}
|
||||
className={`rounded-lg border px-2 py-2 text-center text-[11px] font-semibold transition-colors ${
|
||||
active
|
||||
? 'border-primary bg-primary text-white'
|
||||
: complete
|
||||
? 'border-primary-200 bg-primary-50 text-primary'
|
||||
: 'border-slate-200 bg-white text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{complete ? <CheckCircle2 className="mx-auto mb-1 h-3.5 w-3.5" /> : <span className="mb-1 block">{item.step + 1}</span>}
|
||||
{item.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!loginChannelId && (
|
||||
<Field label="Telegram 手机号" required hint="必须包含国家/地区代码,不要输入空格或短横线">
|
||||
<Input
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={telegramAccount}
|
||||
onChange={(e) => setTelegramAccount(e.target.value)}
|
||||
placeholder="+8613800138000"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{authState === 'waiting_code' && (
|
||||
<Field label="Telegram 验证码" required hint="验证码由 Telegram 官方发送,请勿转发给他人">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
value={telegramCode}
|
||||
onChange={(e) => setTelegramCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||
placeholder="请输入 4—8 位验证码"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{authState === 'waiting_password' && (
|
||||
<Field label="Telegram 二步验证密码" required hint="仅当该 Telegram 账号已启用两步验证时需要">
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={telegramPassword}
|
||||
onChange={(e) => setTelegramPassword(e.target.value)}
|
||||
placeholder="输入 Telegram 二步验证密码"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
手机号、验证码和二步密码只通过加密连接发送到本企业的 Telegram Gateway,不写入浏览器本地存储。授权成功后将自动完成 Webhook 注册和首次历史同步。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* 同步日志 */}
|
||||
@@ -180,6 +671,73 @@ export function ChannelsPage() {
|
||||
</div>
|
||||
))}
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
open={!!settingsDrawer}
|
||||
onOpenChange={() => setSettingsDrawer(null)}
|
||||
title={settingsDrawer ? `${CHANNEL_META[settingsDrawer.type].name} 连接详情` : '连接详情'}
|
||||
width="w-[420px]"
|
||||
>
|
||||
{settingsDrawer && (
|
||||
<div className="space-y-5 p-4">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<ChannelIcon channel={settingsDrawer.type} size="lg" withBg />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{settingsDrawer.account || settingsDrawer.name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{STATUS_MAP[settingsDrawer.status]?.label} · 最近同步 {settingsDrawer.lastSyncAt ? formatRelative(settingsDrawer.lastSyncAt) : '尚未同步'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsDrawer.type === 'email' && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{[
|
||||
['IMAP', `${settingsDrawer.imapHost}:${settingsDrawer.imapPort}`],
|
||||
['SMTP', `${settingsDrawer.smtpHost}:${settingsDrawer.smtpPort}`],
|
||||
['文件夹', settingsDrawer.imapFolder || 'INBOX'],
|
||||
['同步范围', `最近 ${settingsDrawer.syncDays || 30} 天`],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-lg border border-slate-100 p-3">
|
||||
<p className="text-slate-400">{label}</p>
|
||||
<p className="mt-1 break-all font-medium text-slate-700">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center justify-between rounded-xl border border-slate-100 px-3 py-2.5 text-sm">
|
||||
自动回复
|
||||
<Switch
|
||||
checked={settingsDrawer.autoReply}
|
||||
onCheckedChange={async (checked) => {
|
||||
const updated = await updateChannel(settingsDrawer.id, { autoReply: checked })
|
||||
setSettingsDrawer(updated)
|
||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const result = await testChannel(settingsDrawer.id)
|
||||
toast.success(`${result.message} · ${result.latencyMs}ms`)
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
测试收发连接
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react'
|
||||
import { getCustomer, updateCustomer } from '@/services/api/customers'
|
||||
import { createTask } from '@/services/api/crm'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
||||
@@ -22,6 +24,8 @@ import { CountryFlag } from '@/components/common/CountryFlag'
|
||||
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
||||
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
|
||||
import { ChipPicker } from '@/components/customer/CustomerFormDialog'
|
||||
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||
import { CustomerSemanticTag, CustomerTagLegend, customerTagCategory } from '@/components/customer/CustomerSemanticTag'
|
||||
import { CUSTOMER_STAGES, CUSTOMER_TAGS, NEXT_ACTIONS } from '@/config/constants'
|
||||
import { formatRelative, formatDateTime } from '@/utils/format'
|
||||
import { cn } from '@/utils/cn'
|
||||
@@ -31,6 +35,7 @@ export function CustomerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [tagDialog, setTagDialog] = useState(false)
|
||||
|
||||
@@ -52,22 +57,42 @@ export function CustomerDetailPage() {
|
||||
if (isError || !data) return <ErrorState onRetry={() => refetch()} />
|
||||
|
||||
const c = data.customer
|
||||
const createSuggestedTask = async (title: string) => {
|
||||
try {
|
||||
await createTask({
|
||||
title,
|
||||
type: 'revisit',
|
||||
priority: c.riskLevel === 'urgent' || c.riskLevel === 'high' ? 'high' : 'medium',
|
||||
status: 'pending',
|
||||
customerId: c.id,
|
||||
customerName: c.name,
|
||||
ownerId: user?.id || '',
|
||||
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
note: `由客户画像的 AI 建议创建:${title}`,
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整')
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const FILE_ICONS: Record<string, string> = { quote: '📄', contract: '📝', image: '🖼️', doc: '📁', other: '📎' }
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="mx-auto max-w-[1500px] p-4 md:p-7">
|
||||
<button onClick={() => navigate(-1)} className="mb-3 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4 rtl-flip" /> 返回
|
||||
</button>
|
||||
|
||||
{/* 顶部客户画像卡片 */}
|
||||
<Card className="p-4 md:p-5">
|
||||
<Card className="overflow-hidden border-slate-200/80 p-5 md:p-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Avatar name={c.name} src={c.avatarUrl} size="xl" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="eyebrow mb-1">Customer intelligence</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">{c.name}</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.035em] text-slate-950">{c.name}</h1>
|
||||
<CountryFlag code={c.country} />
|
||||
<ChannelIcon channel={c.channel} size="xs" />
|
||||
{c.isKey && <Badge variant="warning">⭐ 重点客户</Badge>}
|
||||
@@ -80,8 +105,19 @@ export function CustomerDetailPage() {
|
||||
{c.riskLevel !== 'none' && <RiskLevelBadge level={c.riskLevel} />}
|
||||
<span className="text-muted-foreground">负责人:{c.ownerId === 'u-003' ? '李婷' : c.ownerId === 'u-004' ? '赵磊' : c.ownerId === 'u-005' ? '周敏' : c.ownerId === 'u-008' ? '郑楠' : '未分配'}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{c.tags.map((t) => <Badge key={t} variant="outline">{t}</Badge>)}
|
||||
<div className="mt-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
|
||||
<CustomerTagLegend tags={c.tags} />
|
||||
{c.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[...c.tags]
|
||||
.sort((a, b) => customerTagCategory(a).localeCompare(customerTagCategory(b)))
|
||||
.map((tag) => <CustomerSemanticTag key={tag} tag={tag} />)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs leading-5 text-slate-400">
|
||||
暂无标签,AI 将在收到有效客户消息后自动补全。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
@@ -119,43 +155,46 @@ export function CustomerDetailPage() {
|
||||
|
||||
<div className="mt-4">
|
||||
{tab === 'overview' && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader title="自动建档流程" description="客户从渠道识别到完整建档案的自动化历程" />
|
||||
<div className="space-y-3 px-4 pb-4">
|
||||
{[
|
||||
'✅ 识别来源渠道(WhatsApp)',
|
||||
'✅ 记录来源参数(官网按钮带入)',
|
||||
'✅ 匹配客户身份(手机号 + 姓名字段)',
|
||||
'✅ 自动发现重复客户(未匹配到重复)',
|
||||
'✅ 人工确认建档(管理员 / 业务主管确认)',
|
||||
'✅ 完整客户档案已建立',
|
||||
].map((step, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className={cn('flex h-5 w-5 items-center justify-center rounded-full text-xs', step.startsWith('✅') ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground')}>{i + 1}</span>
|
||||
{step.replace('✅ ', '')}
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||
<CustomerProfileRadar customer={c} className="xl:col-span-5" />
|
||||
|
||||
<Card className="xl:col-span-7">
|
||||
<CardHeader title="AI 客户洞察" description="结合完整会话、购买信号和风险变化生成" />
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
['成交概率', c.aiProfile.dealProbability || 0, 'text-emerald-700 bg-emerald-50'],
|
||||
['流失风险', c.aiProfile.churnProbability || 0, 'text-amber-700 bg-amber-50'],
|
||||
['分析可信度', c.aiProfile.confidenceScore || 0, 'text-blue-700 bg-blue-50'],
|
||||
].map(([label, value, color]) => (
|
||||
<div key={String(label)} className={cn('rounded-2xl px-3 py-3', String(color))}>
|
||||
<p className="text-[11px] opacity-75">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight">{Number(value)}%</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-5 text-sm leading-7 text-slate-600">
|
||||
{c.aiProfile.summary || 'AI 正在根据 Telegram 会话补全客户画像。'}
|
||||
</p>
|
||||
{c.aiProfile.evidence?.length ? (
|
||||
<div className="mt-5 border-t border-slate-100 pt-4">
|
||||
<p className="text-xs font-semibold text-slate-900">关键判断依据</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{c.aiProfile.evidence.slice(0, 4).map((item) => (
|
||||
<div key={item} className="flex gap-2 text-xs leading-5 text-slate-500">
|
||||
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-400" />
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="AI 客户画像摘要" />
|
||||
<p className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{c.aiProfile.summary}</p>
|
||||
{c.aiProfile.nextActions.length > 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<h4 className="text-xs font-semibold mb-2">推荐动作</h4>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{c.aiProfile.nextActions.map((a) => (
|
||||
<button key={a} onClick={() => toast.info(a)} className="rounded-full border border-primary-200 bg-primary-50 px-2 py-1 text-xs text-primary-700">{a}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
{/* AI 提取信息 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader title="AI 自动提取信息" />
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 px-4 pb-4 text-sm">
|
||||
|
||||
<Card className="xl:col-span-8">
|
||||
<CardHeader title="采购情报" description="从 Telegram 对话中自动结构化提取" />
|
||||
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-b-2xl bg-slate-100 md:grid-cols-4">
|
||||
{Object.entries({
|
||||
询价数量: c.aiProfile.inquiryQuantity,
|
||||
目标价格: c.aiProfile.targetPrice,
|
||||
@@ -165,16 +204,32 @@ export function CustomerDetailPage() {
|
||||
交付时间: c.aiProfile.deliveryTime,
|
||||
物流方式: c.aiProfile.logistics,
|
||||
付款方式: c.aiProfile.payment,
|
||||
售后诉求: c.aiProfile.aftersales,
|
||||
主要顾虑: c.aiProfile.concerns?.join('、'),
|
||||
}).filter(([, v]) => v).map(([k, v]) => (
|
||||
<div key={k} className="rounded-md bg-muted/60 p-2">
|
||||
<span className="text-xs text-muted-foreground">{k}</span>
|
||||
<p className="text-sm font-medium mt-0.5">{v as string}</p>
|
||||
}).map(([k, v]) => (
|
||||
<div key={k} className="min-h-24 bg-white p-4">
|
||||
<span className="text-[11px] font-medium text-slate-400">{k}</span>
|
||||
<p className={cn('mt-2 text-sm font-medium leading-5', v ? 'text-slate-800' : 'text-slate-300')}>
|
||||
{(v as string) || '待识别'}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-4">
|
||||
<CardHeader title="建议下一步" description={c.aiProfile.followUpTiming || '按当前客户热度排序'} />
|
||||
<div className="space-y-2 p-4">
|
||||
{(c.aiProfile.nextActions.length ? c.aiProfile.nextActions : ['补充确认采购数量', '确认目标交期']).slice(0, 5).map((action, index) => (
|
||||
<button
|
||||
key={action}
|
||||
onClick={() => void createSuggestedTask(action)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-slate-100 px-3 py-2.5 text-start text-sm text-slate-700 transition-colors hover:border-primary-200 hover:bg-primary-50"
|
||||
>
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-[11px] font-semibold text-slate-500">{index + 1}</span>
|
||||
{action}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ export function CustomersPage() {
|
||||
<Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr>
|
||||
)}
|
||||
{list.map((customer: Customer) => (
|
||||
<Tr key={customer.id} className="cursor-pointer">
|
||||
<Tr key={customer.id} data-testid="customer-row" className="cursor-pointer">
|
||||
<Td onClick={(e) => e.stopPropagation()}><Checkbox checked={selected.has(customer.id)} onCheckedChange={(v) => setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /></Td>
|
||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -92,43 +92,53 @@ export function DashboardPage() {
|
||||
const s = data.stats
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="mx-auto max-w-[1600px] p-4 md:p-7">
|
||||
{/* 顶部欢迎区 */}
|
||||
<div className="mb-5 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">
|
||||
{greeting},{user?.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{dateStr} · {tenant?.name} · {t('dashboard.updatedAt')} {formatDateTime(data.updatedAt).slice(11)}
|
||||
</p>
|
||||
<div className="mb-7 overflow-hidden rounded-3xl border border-slate-200/80 bg-white px-5 py-5 shadow-[0_16px_50px_rgba(15,23,42,.035)] md:px-7 md:py-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div>
|
||||
<p className="eyebrow">Business command center</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">
|
||||
{greeting},{user?.name}
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-slate-500">
|
||||
{dateStr} · {tenant?.name} · 数据更新于 {formatDateTime(data.updatedAt).slice(11)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="primary" className="h-7 rounded-full px-3">{plan.name}</Badge>
|
||||
<LiveDataBadge />
|
||||
<Button size="sm" onClick={() => navigate('/inbox')}>
|
||||
<Send className="h-4 w-4" />
|
||||
处理客户消息
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="primary" className="h-7 px-2.5">{plan.name}</Badge>
|
||||
<LiveDataBadge />
|
||||
<Button size="sm" onClick={() => navigate('/channels')}>
|
||||
<Cable className="h-4 w-4" />
|
||||
快速连接渠道
|
||||
</Button>
|
||||
<div className="mt-5 flex gap-2 overflow-x-auto border-t border-slate-100 pt-4 scrollbar-thin">
|
||||
{quickActions.slice(0, 5).map((a) => (
|
||||
<Button key={a.label} variant="ghost" size="sm" className="shrink-0 rounded-xl bg-slate-50 text-slate-600 hover:bg-primary-50 hover:text-primary-800" onClick={a.onClick}>
|
||||
<a.icon className="h-3.5 w-3.5" />
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<div className="mb-5 flex flex-wrap gap-2">
|
||||
{quickActions.map((a) => (
|
||||
<Button key={a.label} variant="outline" size="sm" onClick={a.onClick}>
|
||||
<a.icon className="h-3.5 w-3.5" />
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="mb-3 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="eyebrow">Today</p>
|
||||
<h2 className="mt-1 text-lg font-semibold tracking-tight">今日经营重点</h2>
|
||||
</div>
|
||||
<button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatCard title="今日新增询盘" value={s.newInquiriesToday.value} delta={s.newInquiriesToday.delta} icon={MessageSquarePlus} onClick={() => navigate('/inbox')} />
|
||||
<StatCard title="待回复消息" value={s.pendingReply.value} delta={s.pendingReply.delta} icon={MessageSquareWarning} onClick={() => navigate('/inbox?filter=pending')} />
|
||||
<StatCard title="高意向客户" value={s.highIntentCustomers.value} delta={s.highIntentCustomers.delta} icon={Target} onClick={() => navigate('/customers?intentLevel=high')} />
|
||||
<StatCard title="今日待跟进" value={s.todayFollowUps.value} delta={s.todayFollowUps.delta} icon={CalendarClock} onClick={() => navigate('/opportunities?tab=tasks')} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<StatCard title="风险预警" value={s.openRisks.value} delta={s.openRisks.delta} icon={AlertTriangle} onClick={() => navigate('/risks')} />
|
||||
<StatCard title="平均首次响应" value={s.avgFirstResponseMin.value} suffix="分钟" delta={s.avgFirstResponseMin.delta} icon={Timer} invertDelta />
|
||||
<StatCard title="本月客户转化率" value={`${s.monthConversionRate.value}%`} delta={s.monthConversionRate.delta} icon={BadgePercent} />
|
||||
|
||||
+623
-86
@@ -1,7 +1,23 @@
|
||||
import { useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, UserRound, ChevronLeft, MessageCircle, X } from 'lucide-react'
|
||||
import {
|
||||
Archive,
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
BellOff,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
Eraser,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
MoreHorizontal,
|
||||
Palette,
|
||||
Pin,
|
||||
Trash2,
|
||||
UserRound,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
getConversations,
|
||||
getConversation,
|
||||
@@ -12,6 +28,10 @@ import {
|
||||
updateConversation,
|
||||
updateMessage,
|
||||
markConversationRead,
|
||||
openTelegramConversation,
|
||||
runTelegramConversationAction,
|
||||
type TelegramConversationAction,
|
||||
type ConversationListItem,
|
||||
} from '@/services/api/conversations'
|
||||
import { updateCustomer } from '@/services/api/customers'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
@@ -20,11 +40,11 @@ import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { Segmented, type TabItem } from '@/components/ui/tabs'
|
||||
import { Dropdown } from '@/components/ui/dropdown'
|
||||
import { Segmented } from '@/components/ui/tabs'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
||||
import { CountryFlag } from '@/components/common/CountryFlag'
|
||||
import { IntentScoreBadge } from '@/components/common/StatusBadges'
|
||||
import type { ViewMode } from '@/components/inbox/MessageBubble'
|
||||
import type { Conversation, Customer, Message } from '@/types'
|
||||
import { ConversationList } from '@/components/inbox/ConversationList'
|
||||
@@ -35,6 +55,50 @@ import { AiReplies } from '@/components/inbox/AiReplies'
|
||||
import { ConversationInsightBar } from '@/components/inbox/ConversationInsightBar'
|
||||
import { useDebounce } from '@/hooks/useDebounce'
|
||||
import { getWebSocketTicket } from '@/services/api/auth'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
type ChatWallpaper = 'ocean' | 'aurora' | 'sunset' | 'mint'
|
||||
|
||||
const CHAT_WALLPAPERS: Array<{
|
||||
value: ChatWallpaper
|
||||
label: string
|
||||
swatch: string
|
||||
}> = [
|
||||
{ value: 'ocean', label: '清爽蓝', swatch: 'from-blue-300 via-sky-100 to-white' },
|
||||
{ value: 'aurora', label: '极光紫', swatch: 'from-violet-300 via-fuchsia-100 to-cyan-100' },
|
||||
{ value: 'sunset', label: '日落橙', swatch: 'from-orange-200 via-rose-100 to-violet-100' },
|
||||
{ value: 'mint', label: '薄荷绿', swatch: 'from-emerald-200 via-teal-100 to-sky-100' },
|
||||
]
|
||||
|
||||
function sortRealtimeConversations(items: ConversationListItem[]) {
|
||||
return [...items].sort((a, b) => {
|
||||
const pinned = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned))
|
||||
if (pinned) return pinned
|
||||
const bTime = Date.parse(b.lastMessage?.createdAt || b.updatedAt || '') || 0
|
||||
const aTime = Date.parse(a.lastMessage?.createdAt || a.updatedAt || '') || 0
|
||||
if (bTime !== aTime) return bTime - aTime
|
||||
return String(b.id).localeCompare(String(a.id))
|
||||
})
|
||||
}
|
||||
|
||||
function updateRealtimeConversationList(
|
||||
current: { list: ConversationListItem[]; total: number } | undefined,
|
||||
conversationId: string,
|
||||
patch: Partial<ConversationListItem>,
|
||||
) {
|
||||
if (!current) return current
|
||||
let matched = false
|
||||
const list = current.list.map((item) => {
|
||||
if (item.id !== conversationId) return item
|
||||
matched = true
|
||||
return {
|
||||
...item,
|
||||
...patch,
|
||||
customer: patch.customer ? { ...item.customer, ...patch.customer } : item.customer,
|
||||
}
|
||||
})
|
||||
return matched ? { ...current, list: sortRealtimeConversations(list) } : current
|
||||
}
|
||||
|
||||
export function InboxPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
@@ -48,11 +112,21 @@ export function InboxPage() {
|
||||
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const debouncedKeyword = useDebounce(keyword, 300)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('bilingual')
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
const saved = window.localStorage.getItem('lingxi-message-view-mode')
|
||||
return ['original', 'translated', 'bilingual'].includes(String(saved))
|
||||
? saved as ViewMode
|
||||
: 'bilingual'
|
||||
})
|
||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'chat' | 'insight'>('list')
|
||||
const [showInsight, setShowInsight] = useState(false) // desktop right panel toggle
|
||||
const [showAiReplies, setShowAiReplies] = useState(true)
|
||||
const [showAiReplies, setShowAiReplies] = useState(false)
|
||||
const [chatWallpaper, setChatWallpaper] = useState<ChatWallpaper>(() => {
|
||||
const saved = window.localStorage.getItem('lingxi-chat-wallpaper')
|
||||
return CHAT_WALLPAPERS.some((item) => item.value === saved) ? saved as ChatWallpaper : 'ocean'
|
||||
})
|
||||
const [draftText, setDraftText] = useState('')
|
||||
const [draftTranslationCn, setDraftTranslationCn] = useState('')
|
||||
const [replyingTo, setReplyingTo] = useState<{ id: string; text: string } | null>(null)
|
||||
const [noteTarget, setNoteTarget] = useState<Message | null>(null)
|
||||
const [noteDraft, setNoteDraft] = useState('')
|
||||
@@ -61,15 +135,38 @@ export function InboxPage() {
|
||||
message: Message
|
||||
customer: Customer
|
||||
} | null>(null)
|
||||
const [addTelegramOpen, setAddTelegramOpen] = useState(false)
|
||||
const [addTelegramType, setAddTelegramType] = useState<'user' | 'chat' | 'invite'>('user')
|
||||
const [addTelegramIdentifier, setAddTelegramIdentifier] = useState('')
|
||||
const [addTelegramLoading, setAddTelegramLoading] = useState(false)
|
||||
const [conversationActionTarget, setConversationActionTarget] = useState<{
|
||||
item: ConversationListItem
|
||||
action: 'clear' | 'delete' | 'leave'
|
||||
} | null>(null)
|
||||
const [conversationActionLoading, setConversationActionLoading] = useState(false)
|
||||
|
||||
const messageListRef = useRef<HTMLDivElement>(null)
|
||||
const messageContentRef = useRef<HTMLDivElement>(null)
|
||||
const activeConversationQueryKeyRef = useRef<readonly unknown[]>([
|
||||
'conversations',
|
||||
filter,
|
||||
channel,
|
||||
debouncedKeyword,
|
||||
])
|
||||
activeConversationQueryKeyRef.current = ['conversations', filter, channel, debouncedKeyword]
|
||||
|
||||
useEffect(() => {
|
||||
setDraftText('')
|
||||
setDraftTranslationCn('')
|
||||
setReplyingTo(null)
|
||||
setShowAiReplies(true)
|
||||
if (isMobile && convId) setMobilePanel('chat')
|
||||
}, [convId])
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem('lingxi-chat-wallpaper', chatWallpaper)
|
||||
}, [chatWallpaper])
|
||||
|
||||
// 会话列表
|
||||
const { data: convData, isLoading: convLoading } = useQuery({
|
||||
queryKey: ['conversations', filter, channel, debouncedKeyword],
|
||||
@@ -108,6 +205,34 @@ export function InboxPage() {
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [convId, messages?.length, lastMessageId])
|
||||
|
||||
// Keep the newest message visible while translations, attachments and the
|
||||
// surrounding composer finish measuring. Do not override a user scrolling up.
|
||||
useEffect(() => {
|
||||
const container = messageListRef.current
|
||||
const content = messageContentRef.current
|
||||
if (!container || !content || !convId) return
|
||||
|
||||
let initialLayout = true
|
||||
const scrollIfFollowingLatest = () => {
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.clientHeight - container.scrollTop
|
||||
if (initialLayout || distanceFromBottom <= 160) {
|
||||
container.scrollTop = container.scrollHeight
|
||||
}
|
||||
initialLayout = false
|
||||
}
|
||||
|
||||
const frame = requestAnimationFrame(scrollIfFollowingLatest)
|
||||
const observer = new ResizeObserver(scrollIfFollowingLatest)
|
||||
observer.observe(container)
|
||||
observer.observe(content)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [convId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!convId || !messages?.length) return
|
||||
const unreadIds = messages.filter((message) => message.sender === 'customer').slice(-100).map((message) => message.id)
|
||||
@@ -160,8 +285,15 @@ export function InboxPage() {
|
||||
conversation?: Conversation
|
||||
}
|
||||
try { event = JSON.parse(String(raw.data)) } catch { return }
|
||||
const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>(
|
||||
activeConversationQueryKeyRef.current,
|
||||
)
|
||||
const conversationWasVisible = Boolean(
|
||||
event.conversationId
|
||||
&& activeConversationCache?.list.some((item) => item.id === event.conversationId),
|
||||
)
|
||||
if (
|
||||
event.type === 'message.created' &&
|
||||
['message.created', 'message.received'].includes(event.type || '') &&
|
||||
event.conversationId &&
|
||||
event.message?.sender === 'customer' &&
|
||||
event.customer
|
||||
@@ -185,27 +317,38 @@ export function InboxPage() {
|
||||
})
|
||||
queryClient.setQueriesData<{ list: Array<{ id: string; lastMessage?: Message }>; total: number }>(
|
||||
{ queryKey: ['conversations'] },
|
||||
(current) => current ? {
|
||||
...current,
|
||||
list: current.list.map((item) => item.id === event.conversationId
|
||||
? { ...item, lastMessage: event.message }
|
||||
: item),
|
||||
} : current,
|
||||
(current) => {
|
||||
const typed = current as { list: ConversationListItem[]; total: number } | undefined
|
||||
const item = typed?.list.find((entry) => entry.id === event.conversationId)
|
||||
const isNewMessage = ['message.created', 'message.received'].includes(event.type || '')
|
||||
const isCurrentLastMessage =
|
||||
String(item?.lastMessage?.id || '') === String(event.message?.id || '')
|
||||
if (!isNewMessage && !isCurrentLastMessage) return typed
|
||||
return updateRealtimeConversationList(typed, event.conversationId!, {
|
||||
lastMessage: event.message,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
if (event.conversationId && event.customer && event.conversation) {
|
||||
queryClient.setQueryData(['conversation', event.conversationId], {
|
||||
if (event.conversationId && event.conversation) {
|
||||
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => ({
|
||||
conversation: event.conversation,
|
||||
customer: event.customer,
|
||||
})
|
||||
queryClient.setQueriesData<any>({ queryKey: ['conversations'] }, (current: any) => current ? {
|
||||
...current,
|
||||
list: current.list.map((item: any) => item.id === event.conversationId
|
||||
? { ...item, ...event.conversation, customer: event.customer }
|
||||
: item),
|
||||
} : current)
|
||||
customer: event.customer || current?.customer,
|
||||
}))
|
||||
queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>(
|
||||
{ queryKey: ['conversations'] },
|
||||
(current) => updateRealtimeConversationList(current, event.conversationId!, {
|
||||
...event.conversation,
|
||||
...(event.customer ? { customer: event.customer } : {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (
|
||||
event.type === 'sync.completed'
|
||||
|| (event.conversationId && !conversationWasVisible)
|
||||
) {
|
||||
void queryClient.invalidateQueries({ queryKey: ['conversations'] })
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ['conversations'] })
|
||||
}
|
||||
socket.onerror = () => socket?.close()
|
||||
socket.onclose = () => {
|
||||
@@ -245,19 +388,34 @@ export function InboxPage() {
|
||||
[setSearchParams],
|
||||
)
|
||||
|
||||
const setChannel = useCallback(
|
||||
(value: string) => {
|
||||
setSearchParams((prev) => {
|
||||
if (value) prev.set('channel', value)
|
||||
else prev.delete('channel')
|
||||
prev.delete('c')
|
||||
return prev
|
||||
})
|
||||
},
|
||||
[setSearchParams],
|
||||
)
|
||||
|
||||
const sendMsg = useMutation({
|
||||
mutationFn: async ({
|
||||
text,
|
||||
convId: id,
|
||||
autoTranslate,
|
||||
translationCn,
|
||||
}: {
|
||||
text: string
|
||||
convId: string
|
||||
autoTranslate?: boolean
|
||||
translationCn?: string
|
||||
}) => {
|
||||
return sendMessage(id, {
|
||||
text,
|
||||
autoTranslate,
|
||||
translationCn,
|
||||
replyToId: replyingTo?.id,
|
||||
replyPreview: replyingTo?.text,
|
||||
})
|
||||
@@ -272,7 +430,13 @@ export function InboxPage() {
|
||||
|
||||
const handleSend = async (text: string, autoTranslate = true) => {
|
||||
if (!convId) return
|
||||
await sendMsg.mutateAsync({ text, convId, autoTranslate })
|
||||
await sendMsg.mutateAsync({
|
||||
text,
|
||||
convId,
|
||||
autoTranslate,
|
||||
translationCn: draftTranslationCn || undefined,
|
||||
})
|
||||
setDraftTranslationCn('')
|
||||
}
|
||||
|
||||
const handleMedia = async (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => {
|
||||
@@ -293,6 +457,87 @@ export function InboxPage() {
|
||||
for (const item of encoded) await sendMedia(convId, { ...item, kind })
|
||||
}
|
||||
|
||||
const refreshConversationState = async (id?: string) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['conversations'] })
|
||||
if (id) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['conversation', id] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['messages', id] }),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
const executeTelegramAction = async (
|
||||
action: TelegramConversationAction,
|
||||
item?: ConversationListItem,
|
||||
) => {
|
||||
const id = item?.id || convId
|
||||
if (!id) return
|
||||
try {
|
||||
const result = await runTelegramConversationAction(id, action)
|
||||
if ('deleted' in result && result.deleted) {
|
||||
queryClient.removeQueries({ queryKey: ['messages', id] })
|
||||
queryClient.removeQueries({ queryKey: ['conversation', id] })
|
||||
if (convId === id) setSearchParams((params) => { params.delete('c'); return params })
|
||||
} else {
|
||||
await refreshConversationState(id)
|
||||
}
|
||||
const labels: Partial<Record<TelegramConversationAction, string>> = {
|
||||
clear: '消息记录已清空',
|
||||
delete: '会话已从 Telegram 列表删除',
|
||||
pin: '会话已置顶',
|
||||
unpin: '已取消置顶',
|
||||
archive: '会话已归档',
|
||||
unarchive: '会话已移出归档',
|
||||
mute: '会话已静音 8 小时',
|
||||
unmute: '会话已恢复通知',
|
||||
'mark-unread': '会话已标记为未读',
|
||||
leave: '已退出群组或频道',
|
||||
block: '已拉黑该 Telegram 用户',
|
||||
unblock: '已解除拉黑',
|
||||
}
|
||||
toast.success(labels[action] || 'Telegram 操作已完成')
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const confirmTelegramAction = async () => {
|
||||
if (!conversationActionTarget) return
|
||||
setConversationActionLoading(true)
|
||||
try {
|
||||
await executeTelegramAction(conversationActionTarget.action, conversationActionTarget.item)
|
||||
setConversationActionTarget(null)
|
||||
} finally {
|
||||
setConversationActionLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addTelegramConversation = async () => {
|
||||
const identifier = addTelegramIdentifier.trim()
|
||||
if (!identifier) {
|
||||
toast.error('请输入 Telegram 标识')
|
||||
return
|
||||
}
|
||||
setAddTelegramLoading(true)
|
||||
try {
|
||||
const value = await openTelegramConversation({
|
||||
identifier,
|
||||
targetType: addTelegramType,
|
||||
})
|
||||
await refreshConversationState(value.conversation.id)
|
||||
setAddTelegramOpen(false)
|
||||
setAddTelegramIdentifier('')
|
||||
setConv(value.conversation.id)
|
||||
toast.success('Telegram 会话已添加', value.customer.name)
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setAddTelegramLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = {
|
||||
quote: (msg: Message) => {
|
||||
if (!convId) return
|
||||
@@ -391,8 +636,127 @@ export function InboxPage() {
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
const handleAdopt = (content: string) => {
|
||||
const telegramDialogs = (
|
||||
<>
|
||||
<Dialog
|
||||
open={addTelegramOpen}
|
||||
onOpenChange={setAddTelegramOpen}
|
||||
title="添加 Telegram 会话"
|
||||
description="通过真实 Telegram 标识创建或加入会话,添加后立即同步最近消息。"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setAddTelegramOpen(false)}>取消</Button>
|
||||
<Button
|
||||
onClick={addTelegramConversation}
|
||||
loading={addTelegramLoading}
|
||||
disabled={!addTelegramIdentifier.trim()}
|
||||
>
|
||||
添加并打开
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-2 rounded-xl bg-slate-100 p-1">
|
||||
{[
|
||||
{ value: 'user' as const, label: '用户' },
|
||||
{ value: 'chat' as const, label: '群组/频道' },
|
||||
{ value: 'invite' as const, label: '邀请链接' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setAddTelegramType(option.value)}
|
||||
className={cn(
|
||||
'rounded-lg px-3 py-2 text-xs font-semibold transition',
|
||||
addTelegramType === 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>
|
||||
<label htmlFor="telegram-identifier" className="mb-1.5 block text-xs font-semibold text-slate-700">
|
||||
{addTelegramType === 'user'
|
||||
? '用户名、手机号或用户 ID'
|
||||
: addTelegramType === 'chat'
|
||||
? '群组/频道用户名或 Chat ID'
|
||||
: 'Telegram 邀请链接'}
|
||||
</label>
|
||||
<input
|
||||
id="telegram-identifier"
|
||||
autoFocus
|
||||
value={addTelegramIdentifier}
|
||||
onChange={(event) => setAddTelegramIdentifier(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !addTelegramLoading) void addTelegramConversation()
|
||||
}}
|
||||
placeholder={
|
||||
addTelegramType === 'user'
|
||||
? '@username、+1 234… 或 123456789'
|
||||
: addTelegramType === 'chat'
|
||||
? '@groupname 或 -1001234567890'
|
||||
: '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"
|
||||
/>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-500">
|
||||
用户 ID 与 Chat ID 含义不同,请先选择正确类型。邀请链接加入群组可能需要管理员审批。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={conversationActionTarget !== null}
|
||||
onOpenChange={(open) => { if (!open && !conversationActionLoading) setConversationActionTarget(null) }}
|
||||
title={
|
||||
conversationActionTarget?.action === 'delete'
|
||||
? '删除 Telegram 会话'
|
||||
: conversationActionTarget?.action === 'leave'
|
||||
? '退出群组或频道'
|
||||
: '清空消息记录'
|
||||
}
|
||||
description={`当前会话:${conversationActionTarget?.item.customer.name || ''}`}
|
||||
persistent={conversationActionLoading}
|
||||
footer={(
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setConversationActionTarget(null)}
|
||||
disabled={conversationActionLoading}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant={conversationActionTarget?.action === 'clear' ? 'default' : 'destructive'}
|
||||
onClick={confirmTelegramAction}
|
||||
loading={conversationActionLoading}
|
||||
>
|
||||
确认执行
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900">
|
||||
{conversationActionTarget?.action === 'delete'
|
||||
? String(conversationActionTarget.item.telegramChatType || '').toLowerCase() === 'channel'
|
||||
? '这会先退出 Telegram 频道,再从当前账号和灵犀跨境中移除会话;不会全局删除频道帖子,也不会删除客户档案。'
|
||||
: '这会清除当前 Telegram 账号中的聊天历史并从会话列表移除,但不会删除客户档案,也不会撤回客户已收到的消息。'
|
||||
: conversationActionTarget?.action === 'leave'
|
||||
? '退出后将不能继续收发该群组或频道消息,重新加入可能需要新的邀请链接或管理员审批。'
|
||||
: '这会清除当前 Telegram 账号和灵犀跨境中的聊天记录,但不会撤回客户已收到的消息。'}
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
|
||||
const handleAdopt = (content: string, translationCn: string) => {
|
||||
setDraftText(content)
|
||||
setDraftTranslationCn(translationCn)
|
||||
setShowAiReplies(false)
|
||||
if (convId) {
|
||||
void import('@/services/api/ai').then(({ recordAiAdoption }) => recordAiAdoption(convId, content))
|
||||
@@ -400,25 +764,64 @@ export function InboxPage() {
|
||||
toast.success('回复已采用,请在输入区编辑后发送')
|
||||
}
|
||||
|
||||
const handleEdit = (content: string) => {
|
||||
const handleEdit = (content: string, translationCn: string) => {
|
||||
setDraftText(content)
|
||||
setDraftTranslationCn(translationCn)
|
||||
setShowAiReplies(false)
|
||||
toast.info('回复已加载到输入区,请按需编辑', content.slice(0, 50))
|
||||
}
|
||||
|
||||
const VIEW_LABELS: TabItem[] = [
|
||||
{ value: 'original', label: '原文' },
|
||||
{ value: 'translated', label: '译文' },
|
||||
{ value: 'bilingual', label: '双语' },
|
||||
]
|
||||
const handleViewModeChange = (nextMode: ViewMode) => {
|
||||
setViewMode(nextMode)
|
||||
window.localStorage.setItem('lingxi-message-view-mode', nextMode)
|
||||
}
|
||||
|
||||
const listItems = convData?.list ?? []
|
||||
const renderMessageTimeline = () => {
|
||||
let previousDay = ''
|
||||
const today = new Date().toDateString()
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toDateString()
|
||||
return (messages ?? []).map((msg) => {
|
||||
const messageDate = new Date(msg.createdAt)
|
||||
const dayKey = messageDate.toDateString()
|
||||
const showDivider = dayKey !== previousDay
|
||||
previousDay = dayKey
|
||||
const dayLabel = dayKey === today
|
||||
? '今天'
|
||||
: dayKey === yesterday
|
||||
? '昨天'
|
||||
: new Intl.DateTimeFormat('zh-CN', { month: 'long', day: 'numeric', weekday: 'short' }).format(messageDate)
|
||||
return (
|
||||
<div key={msg.id}>
|
||||
{showDivider && (
|
||||
<div className="sticky top-2 z-10 flex justify-center py-2">
|
||||
<span className="rounded-full border border-white/80 bg-white/85 px-3 py-1 text-[10px] font-medium text-slate-500 shadow-sm backdrop-blur-md">
|
||||
{dayLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageBubble
|
||||
msg={msg}
|
||||
viewMode={viewMode}
|
||||
onQuote={() => handleAction.quote(msg)}
|
||||
onMarkImportant={() => handleAction.markImportant(msg)}
|
||||
onConvertToTask={() => handleAction.task(msg)}
|
||||
onAddToKnowledge={() => handleAction.knowledge(msg)}
|
||||
onAddNote={() => handleAction.addNote(msg)}
|
||||
customerName={convDetail?.customer.name}
|
||||
customerAvatar={convDetail?.customer.avatarUrl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 移动端:当前面板
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-56px)] flex-col">
|
||||
<div className="flex h-[calc(100vh-64px)] min-h-0 flex-col overflow-hidden bg-white">
|
||||
{noteDialog}
|
||||
{telegramDialogs}
|
||||
{incomingNotice && (
|
||||
<IncomingMessageNotice
|
||||
notice={incomingNotice}
|
||||
@@ -439,16 +842,32 @@ export function InboxPage() {
|
||||
onFilterChange={setFilter}
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
channel={channel}
|
||||
onChannelChange={setChannel}
|
||||
onAdd={() => setAddTelegramOpen(true)}
|
||||
onClear={(item) => setConversationActionTarget({ item, action: 'clear' })}
|
||||
onDelete={(item) => setConversationActionTarget({ item, action: 'delete' })}
|
||||
/>
|
||||
)}
|
||||
{mobilePanel === 'chat' && (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<ChatHeader
|
||||
convId={convId}
|
||||
detail={convDetail}
|
||||
loading={convDetailLoading}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
wallpaper={chatWallpaper}
|
||||
onWallpaperChange={setChatWallpaper}
|
||||
onTelegramAction={(action) => {
|
||||
const item = listItems.find((entry) => entry.id === convId)
|
||||
if (!item) return
|
||||
if (['clear', 'delete', 'leave'].includes(action)) {
|
||||
setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' })
|
||||
} else {
|
||||
void executeTelegramAction(action, item)
|
||||
}
|
||||
}}
|
||||
onBack={() => setMobilePanel('list')}
|
||||
onInsight={() => setMobilePanel('insight')}
|
||||
showInsightBtn
|
||||
@@ -460,23 +879,17 @@ export function InboxPage() {
|
||||
onOpenProfile={() => setMobilePanel('insight')}
|
||||
/>
|
||||
)}
|
||||
<div ref={messageListRef} data-testid="message-list" className="flex-1 overflow-y-auto bg-slate-50/60 scrollbar-thin">
|
||||
{msgsLoading ? <PageLoading /> : <>
|
||||
{(messages ?? []).map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
viewMode={viewMode}
|
||||
onQuote={() => handleAction.quote(msg)}
|
||||
onMarkImportant={() => handleAction.markImportant(msg)}
|
||||
onConvertToTask={() => handleAction.task(msg)}
|
||||
onAddToKnowledge={() => handleAction.knowledge(msg)}
|
||||
onAddNote={() => handleAction.addNote(msg)}
|
||||
customerName={convDetail?.customer.name}
|
||||
customerAvatar={convDetail?.customer.avatarUrl}
|
||||
/>
|
||||
))}
|
||||
</>}
|
||||
<div ref={messageListRef} data-testid="message-list" className={`chat-wallpaper chat-wallpaper-${chatWallpaper} min-h-0 flex-1 overflow-y-auto overscroll-contain scrollbar-thin`}>
|
||||
<div
|
||||
ref={messageContentRef}
|
||||
data-testid="message-content"
|
||||
className={cn(
|
||||
'min-h-full',
|
||||
!msgsLoading && (messages?.length ?? 0) > 0 && 'flex flex-col justify-end',
|
||||
)}
|
||||
>
|
||||
{msgsLoading ? <PageLoading /> : renderMessageTimeline()}
|
||||
</div>
|
||||
</div>
|
||||
{showAiReplies && convId ? (
|
||||
<AiReplies
|
||||
@@ -496,10 +909,14 @@ export function InboxPage() {
|
||||
onSend={handleSend}
|
||||
onMedia={handleMedia}
|
||||
value={draftText}
|
||||
onChange={setDraftText}
|
||||
onChange={(text) => {
|
||||
setDraftText(text)
|
||||
setDraftTranslationCn('')
|
||||
}}
|
||||
disabled={!convId}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
channel={convDetail?.conversation.channel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -520,8 +937,9 @@ export function InboxPage() {
|
||||
|
||||
// 桌面端三栏布局
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-56px)] overflow-hidden">
|
||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-white">
|
||||
{noteDialog}
|
||||
{telegramDialogs}
|
||||
{incomingNotice && (
|
||||
<IncomingMessageNotice
|
||||
notice={incomingNotice}
|
||||
@@ -533,7 +951,10 @@ export function InboxPage() {
|
||||
/>
|
||||
)}
|
||||
{/* 左栏:会话列表 360px */}
|
||||
<div className="w-80 shrink-0 border-e border-border lg:w-[360px]">
|
||||
<div
|
||||
data-testid="conversation-list-panel"
|
||||
className="relative z-20 w-80 shrink-0 border-e border-slate-300/90 bg-white lg:w-[360px]"
|
||||
>
|
||||
<ConversationList
|
||||
items={listItems}
|
||||
loading={convLoading}
|
||||
@@ -543,11 +964,19 @@ export function InboxPage() {
|
||||
onFilterChange={setFilter}
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
channel={channel}
|
||||
onChannelChange={setChannel}
|
||||
onAdd={() => setAddTelegramOpen(true)}
|
||||
onClear={(item) => setConversationActionTarget({ item, action: 'clear' })}
|
||||
onDelete={(item) => setConversationActionTarget({ item, action: 'delete' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 中栏:聊天区 flex-1 */}
|
||||
<div className={`flex min-w-0 flex-1 flex-col ${showInsight ? '' : 'border-e-0'}`}>
|
||||
<div
|
||||
data-testid="chat-panel"
|
||||
className={`relative z-10 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[#eaf2f9] ${showInsight ? '' : 'border-e-0'}`}
|
||||
>
|
||||
{convId ? (
|
||||
<>
|
||||
<ChatHeader
|
||||
@@ -555,7 +984,18 @@ export function InboxPage() {
|
||||
detail={convDetail}
|
||||
loading={convDetailLoading}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
wallpaper={chatWallpaper}
|
||||
onWallpaperChange={setChatWallpaper}
|
||||
onTelegramAction={(action) => {
|
||||
const item = listItems.find((entry) => entry.id === convId)
|
||||
if (!item) return
|
||||
if (['clear', 'delete', 'leave'].includes(action)) {
|
||||
setConversationActionTarget({ item, action: action as 'clear' | 'delete' | 'leave' })
|
||||
} else {
|
||||
void executeTelegramAction(action, item)
|
||||
}
|
||||
}}
|
||||
onInsight={() => setShowInsight((v) => !v)}
|
||||
showInsightBtn
|
||||
/>
|
||||
@@ -566,27 +1006,21 @@ export function InboxPage() {
|
||||
onOpenProfile={() => setShowInsight(true)}
|
||||
/>
|
||||
)}
|
||||
<div ref={messageListRef} data-testid="message-list" className="flex-1 overflow-y-auto bg-slate-50/60 scrollbar-thin">
|
||||
{msgsLoading ? (
|
||||
<PageLoading />
|
||||
) : (messages?.length ?? 0) === 0 ? (
|
||||
<EmptyState title="暂无消息" description="选择左侧会话开始沟通" />
|
||||
) : (
|
||||
(messages ?? []).map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
viewMode={viewMode}
|
||||
onQuote={() => handleAction.quote(msg)}
|
||||
onMarkImportant={() => handleAction.markImportant(msg)}
|
||||
onConvertToTask={() => handleAction.task(msg)}
|
||||
onAddToKnowledge={() => handleAction.knowledge(msg)}
|
||||
onAddNote={() => handleAction.addNote(msg)}
|
||||
customerName={convDetail?.customer.name}
|
||||
customerAvatar={convDetail?.customer.avatarUrl}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<div ref={messageListRef} data-testid="message-list" className={`chat-wallpaper chat-wallpaper-${chatWallpaper} min-h-0 flex-1 overflow-y-auto overscroll-contain scrollbar-thin`}>
|
||||
<div
|
||||
ref={messageContentRef}
|
||||
data-testid="message-content"
|
||||
className={cn(
|
||||
'min-h-full',
|
||||
!msgsLoading && (messages?.length ?? 0) > 0 && 'flex flex-col justify-end',
|
||||
)}
|
||||
>
|
||||
{msgsLoading ? (
|
||||
<PageLoading />
|
||||
) : (messages?.length ?? 0) === 0 ? (
|
||||
<EmptyState title="暂无消息" description="选择左侧会话开始沟通" />
|
||||
) : renderMessageTimeline()}
|
||||
</div>
|
||||
</div>
|
||||
{showAiReplies ? (
|
||||
<AiReplies
|
||||
@@ -606,10 +1040,14 @@ export function InboxPage() {
|
||||
onSend={handleSend}
|
||||
onMedia={handleMedia}
|
||||
value={draftText}
|
||||
onChange={setDraftText}
|
||||
onChange={(text) => {
|
||||
setDraftText(text)
|
||||
setDraftTranslationCn('')
|
||||
}}
|
||||
disabled={!convId}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
channel={convDetail?.conversation.channel}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -623,7 +1061,7 @@ export function InboxPage() {
|
||||
|
||||
{/* 右栏:客户洞察 340px */}
|
||||
{showInsight && convDetail && (
|
||||
<div className="w-[340px] shrink-0 border-s border-border">
|
||||
<div className="w-[380px] shrink-0 border-s border-slate-200/80 bg-slate-50/40">
|
||||
<InsightPanel customer={convDetail.customer} conversation={convDetail.conversation} />
|
||||
</div>
|
||||
)}
|
||||
@@ -638,6 +1076,9 @@ function ChatHeader({
|
||||
loading,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
wallpaper,
|
||||
onWallpaperChange,
|
||||
onTelegramAction,
|
||||
onBack,
|
||||
onInsight,
|
||||
showInsightBtn,
|
||||
@@ -647,6 +1088,9 @@ function ChatHeader({
|
||||
loading: boolean
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (v: ViewMode) => void
|
||||
wallpaper: ChatWallpaper
|
||||
onWallpaperChange: (wallpaper: ChatWallpaper) => void
|
||||
onTelegramAction: (action: TelegramConversationAction) => void
|
||||
onBack?: () => void
|
||||
onInsight?: () => void
|
||||
showInsightBtn?: boolean
|
||||
@@ -654,8 +1098,9 @@ function ChatHeader({
|
||||
if (!convId) return null
|
||||
const c = detail?.customer
|
||||
const conv = detail?.conversation
|
||||
const isTelegramChannel = String(conv?.telegramChatType || c?.telegramChatType || '').toLowerCase() === 'channel'
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5">
|
||||
<div className="relative z-50 flex min-h-16 shrink-0 items-center gap-2 overflow-visible border-b border-slate-100 bg-white/95 px-2.5 py-2.5 backdrop-blur-xl sm:gap-3 sm:px-4">
|
||||
{onBack && (
|
||||
<Button variant="ghost" size="icon-sm" onClick={onBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@@ -665,21 +1110,28 @@ function ChatHeader({
|
||||
<span className="text-sm text-muted-foreground">加载中…</span>
|
||||
) : (
|
||||
<>
|
||||
<Avatar name={c?.name ?? '?'} src={c?.avatarUrl} size="sm" />
|
||||
<div className="relative">
|
||||
<Avatar name={c?.name ?? '?'} src={c?.avatarUrl} size="md" />
|
||||
<span className="absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{c?.name ?? '—'}</span>
|
||||
<span className="truncate text-sm font-semibold text-slate-950">{c?.name ?? '—'}</span>
|
||||
{c && <CountryFlag code={c.country} withName={false} />}
|
||||
{conv && <ChannelIcon channel={conv.channel} size="xs" withBg />}
|
||||
{c && <IntentScoreBadge score={c.intentScore} className="scale-75" />}
|
||||
</div>
|
||||
{c?.telegramUsername && (
|
||||
<p className="truncate text-[11px] text-muted-foreground">@{c.telegramUsername}</p>
|
||||
)}
|
||||
<p className="truncate text-[11px] text-slate-400">
|
||||
{conv?.channel === 'email'
|
||||
? conv.emailSubject || c?.email || 'Email 会话'
|
||||
: c?.telegramUsername
|
||||
? `@${c.telegramUsername} · 在线`
|
||||
: 'Telegram 会话'}
|
||||
</p>
|
||||
</div>
|
||||
<Segmented
|
||||
value={viewMode}
|
||||
onChange={onViewModeChange}
|
||||
ariaLabel="消息显示语言"
|
||||
options={[
|
||||
{ value: 'original' as ViewMode, label: '原文' },
|
||||
{ value: 'translated' as ViewMode, label: '译文' },
|
||||
@@ -687,6 +1139,91 @@ function ChatHeader({
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
<Dropdown
|
||||
trigger={(
|
||||
<Button variant="ghost" size="icon-sm" title="切换聊天背景" aria-label="切换聊天背景">
|
||||
<Palette className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
items={CHAT_WALLPAPERS.map((item) => ({
|
||||
key: item.value,
|
||||
label: (
|
||||
<span className="flex flex-1 items-center gap-2.5">
|
||||
<span className={`h-5 w-8 rounded-md border border-white/80 bg-gradient-to-br ${item.swatch} shadow-sm`} />
|
||||
<span>{item.label}</span>
|
||||
{wallpaper === item.value && <Check className="ml-auto h-3.5 w-3.5 text-primary" />}
|
||||
</span>
|
||||
),
|
||||
onSelect: () => onWallpaperChange(item.value),
|
||||
}))}
|
||||
/>
|
||||
{conv?.channel === 'telegram' && (
|
||||
<Dropdown
|
||||
trigger={(
|
||||
<Button variant="ghost" size="icon-sm" title="Telegram 会话操作" aria-label="Telegram 会话操作">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
items={[
|
||||
{
|
||||
key: conv.isPinned ? 'unpin' : 'pin',
|
||||
label: conv.isPinned ? '取消置顶' : '置顶会话',
|
||||
icon: Pin,
|
||||
onSelect: () => onTelegramAction(conv.isPinned ? 'unpin' : 'pin'),
|
||||
},
|
||||
{
|
||||
key: conv.archived ? 'unarchive' : 'archive',
|
||||
label: conv.archived ? '移出归档' : '归档会话',
|
||||
icon: Archive,
|
||||
onSelect: () => onTelegramAction(conv.archived ? 'unarchive' : 'archive'),
|
||||
},
|
||||
{
|
||||
key: conv.mutedUntil ? 'unmute' : 'mute',
|
||||
label: conv.mutedUntil ? '恢复通知' : '静音 8 小时',
|
||||
icon: BellOff,
|
||||
onSelect: () => onTelegramAction(conv.mutedUntil ? 'unmute' : 'mute'),
|
||||
},
|
||||
{
|
||||
key: 'mark-unread',
|
||||
label: '标记为未读',
|
||||
icon: MessageCircle,
|
||||
onSelect: () => onTelegramAction('mark-unread'),
|
||||
},
|
||||
{
|
||||
key: conv.isBlocked ? 'unblock' : 'block',
|
||||
label: conv.isBlocked ? '解除拉黑' : '拉黑用户',
|
||||
icon: Ban,
|
||||
disabled: !c?.telegramUserId,
|
||||
onSelect: () => onTelegramAction(conv.isBlocked ? 'unblock' : 'block'),
|
||||
},
|
||||
'separator',
|
||||
...(!isTelegramChannel
|
||||
? [{
|
||||
key: 'clear',
|
||||
label: '清空消息',
|
||||
icon: Eraser,
|
||||
onSelect: () => onTelegramAction('clear' as TelegramConversationAction),
|
||||
}]
|
||||
: []),
|
||||
...(String(conv.telegramChatType || c?.telegramChatType || '').toLowerCase() !== 'private'
|
||||
? [{
|
||||
key: 'leave',
|
||||
label: '退出群组/频道',
|
||||
icon: LogOut,
|
||||
danger: true,
|
||||
onSelect: () => onTelegramAction('leave' as TelegramConversationAction),
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除会话',
|
||||
icon: Trash2,
|
||||
danger: true,
|
||||
onSelect: () => onTelegramAction('delete'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{showInsightBtn && onInsight && (
|
||||
<Button variant="ghost" size="icon-sm" onClick={onInsight} title="客户洞察">
|
||||
<UserRound className="h-4 w-4" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { PageLoading, ErrorState } from '@/components/ui/feedback'
|
||||
@@ -20,10 +21,12 @@ import { AlertTriangle, Settings } from 'lucide-react'
|
||||
|
||||
export function RisksPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [level, setLevel] = useState('')
|
||||
const [type, setType] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [detailId, setDetailId] = useState<string | null>(null)
|
||||
const [noteDraft, setNoteDraft] = useState('')
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['risks', level, type, status],
|
||||
@@ -54,8 +57,8 @@ export function RisksPage() {
|
||||
title="风险预警中心"
|
||||
description="智能识别交易风险、客户流失信号与敏感表达,帮助团队主动应对"
|
||||
actions={
|
||||
<PermissionGate permission="risk:handle">
|
||||
<Button size="sm" onClick={() => toast.info('风险处理操作已可用')}><Settings className="h-4 w-4" />规则设置</Button>
|
||||
<PermissionGate permission="settings:manage">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate('/settings')}><Settings className="h-4 w-4" />风险识别设置</Button>
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
@@ -77,7 +80,10 @@ export function RisksPage() {
|
||||
{list.map((r) => {
|
||||
const meta = riskLevelMeta(r.level)
|
||||
return (
|
||||
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => setDetailId(r.id)}>
|
||||
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => {
|
||||
setDetailId(r.id)
|
||||
setNoteDraft(r.note || '')
|
||||
}}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className={cn('mt-0.5 h-2 w-2 shrink-0 rounded-full', meta.dot)} />
|
||||
@@ -119,8 +125,15 @@ export function RisksPage() {
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-1">添加备注</h4>
|
||||
<Textarea rows={3} placeholder="添加处理备注…" />
|
||||
<Button size="sm" className="mt-2" onClick={() => { updateMut.mutate({ id: detail.id, data: { note: '已备注' } }); toast.success('备注已保存') }}>保存备注</Button>
|
||||
<Textarea rows={3} placeholder="添加处理备注…" value={noteDraft} onChange={(event) => setNoteDraft(event.target.value)} />
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
disabled={!noteDraft.trim()}
|
||||
onClick={() => updateMut.mutate({ id: detail.id, data: { note: noteDraft.trim() } })}
|
||||
>
|
||||
保存备注
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { ArrowLeftRight, Copy, FileText, History, Languages, RefreshCw, Save } from 'lucide-react'
|
||||
import { translate } from '@/services/api/ai'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeftRight, Copy, Languages, RefreshCw, Save } from 'lucide-react'
|
||||
import { favoriteTranslation, getTranslationHistory, translate } from '@/services/api/ai'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -19,6 +19,7 @@ import { cn } from '@/utils/cn'
|
||||
const SAMPLE_TEXT = '你好,非常感谢贵司对500个定制双层玻璃杯的询盘。我们很高兴为您提供报价:单价$1.85/个(含双色Logo印刷),标准交期12-15天,加急10天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。'
|
||||
|
||||
export function TranslatePage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [tab, setTab] = useState('text')
|
||||
const [source, setSource] = useState('')
|
||||
const [target, setTarget] = useState('en')
|
||||
@@ -26,7 +27,11 @@ export function TranslatePage() {
|
||||
const [result, setResult] = useState('')
|
||||
const [detected, setDetected] = useState('')
|
||||
const [terms, setTerms] = useState<any[]>([])
|
||||
const [history, setHistory] = useState<{ source: string; target: string; text: string; result: string }[]>([])
|
||||
const [currentRecordId, setCurrentRecordId] = useState('')
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ['translation-history'],
|
||||
queryFn: getTranslationHistory,
|
||||
})
|
||||
|
||||
const translateMut = useMutation({
|
||||
mutationFn: () => translate({ text, sourceLang: source || undefined, targetLang: target }),
|
||||
@@ -34,10 +39,19 @@ export function TranslatePage() {
|
||||
setResult(r.translated)
|
||||
setDetected(r.detectedLang)
|
||||
setTerms(r.terms ?? [])
|
||||
setHistory((h) => [{ source: source || r.detectedLang, target, text, result: r.translated }, ...h].slice(0, 20))
|
||||
setCurrentRecordId(r.historyItem?.id || '')
|
||||
void queryClient.invalidateQueries({ queryKey: ['translation-history'] })
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
})
|
||||
const favoriteMut = useMutation({
|
||||
mutationFn: () => favoriteTranslation(currentRecordId, true),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['translation-history'] })
|
||||
toast.success('已收藏到翻译历史')
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
})
|
||||
|
||||
const swap = () => {
|
||||
if (detected && !source) setSource(detected)
|
||||
@@ -131,8 +145,7 @@ export function TranslatePage() {
|
||||
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { navigator.clipboard.writeText(result); toast.success('已复制') }}>
|
||||
<Copy className="h-3.5 w-3.5" /> 复制
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { toast.success('已收藏');
|
||||
}}>
|
||||
<Button size="sm" variant="ghost" disabled={!result || !currentRecordId || favoriteMut.isPending} onClick={() => favoriteMut.mutate()}>
|
||||
<Save className="h-3.5 w-3.5" /> 收藏
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => translateMut.mutate()}>
|
||||
@@ -154,10 +167,11 @@ export function TranslatePage() {
|
||||
<div key={i} className="p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||
<Languages className="h-3 w-3" />
|
||||
{LANGUAGES.find(l => l.code === h.source)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.target)?.name}
|
||||
{LANGUAGES.find(l => l.code === h.sourceLang)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.targetLang)?.name}
|
||||
{h.favorite && <Badge className="ml-auto bg-amber-50 text-amber-700">已收藏</Badge>}
|
||||
</div>
|
||||
<p className="text-xs truncate">{h.text.slice(0, 80)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.result.slice(0, 80)}</p>
|
||||
<p className="text-xs truncate">{h.originalText.slice(0, 80)}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.translatedText.slice(0, 80)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { http } from './client'
|
||||
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult } from '@/types'
|
||||
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult, TranslationHistoryItem } from '@/types'
|
||||
|
||||
/** POST /api/ai/translate */
|
||||
export function translate(payload: TranslatePayload) {
|
||||
return http.post<TranslateResult>('/api/ai/translate', payload)
|
||||
}
|
||||
|
||||
export function getTranslationHistory() {
|
||||
return http.get<TranslationHistoryItem[]>('/api/ai/translations')
|
||||
}
|
||||
|
||||
export function favoriteTranslation(id: string, favorite: boolean) {
|
||||
return http.patch<TranslationHistoryItem>(`/api/ai/translations/${id}`, { favorite })
|
||||
}
|
||||
|
||||
/** POST /api/ai/analyze */
|
||||
export function analyzeConversation(conversationId: string) {
|
||||
return http.post<AiAnalysis>('/api/ai/analyze', { conversationId })
|
||||
|
||||
@@ -21,6 +21,23 @@ export function startTelegramLogin(phone: string) {
|
||||
return http.post<ChannelConnection>('/api/channels/telegram/start', { phone })
|
||||
}
|
||||
|
||||
export function connectEmailChannel(payload: {
|
||||
name?: string
|
||||
displayName?: string
|
||||
account: string
|
||||
password: string
|
||||
imapHost: string
|
||||
imapPort: number
|
||||
imapSecure: boolean
|
||||
imapFolder?: string
|
||||
smtpHost: string
|
||||
smtpPort: number
|
||||
smtpSecure: boolean
|
||||
syncDays?: number
|
||||
}) {
|
||||
return http.post<ChannelConnection>('/api/channels/email/connect', payload)
|
||||
}
|
||||
|
||||
export function submitTelegramCode(channelId: string, code: string) {
|
||||
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/code`, { code })
|
||||
}
|
||||
@@ -29,6 +46,12 @@ export function submitTelegramPassword(channelId: string, password: string) {
|
||||
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/password`, { password })
|
||||
}
|
||||
|
||||
export function getTelegramLoginState(channelId: string) {
|
||||
return http.post<{ status: string; connectionState?: string; channel: ChannelConnection }>(
|
||||
`/api/channels/telegram/${channelId}/state`,
|
||||
)
|
||||
}
|
||||
|
||||
export function syncTelegramChannel(channelId: string) {
|
||||
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`)
|
||||
}
|
||||
|
||||
@@ -33,9 +33,10 @@ export function markConversationRead(id: string, messageIds?: string[]) {
|
||||
return http.post<Conversation>(`/api/conversations/${id}/read`, { messageIds })
|
||||
}
|
||||
|
||||
export function getMessageAttachment(id: string, messageId: string) {
|
||||
export function getMessageAttachment(id: string, messageId: string, attachmentId?: string) {
|
||||
return http.get<{ dataUrl: string; mimeType: string; name: string }>(
|
||||
`/api/conversations/${id}/messages/${messageId}/attachment`,
|
||||
attachmentId ? { attachmentId } : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,3 +63,39 @@ export function sendAlbum(
|
||||
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
|
||||
return http.patch<Conversation>(`/api/conversations/${id}`, payload)
|
||||
}
|
||||
|
||||
export type TelegramConversationAction =
|
||||
| 'clear'
|
||||
| 'delete'
|
||||
| 'pin'
|
||||
| 'unpin'
|
||||
| 'archive'
|
||||
| 'unarchive'
|
||||
| 'mute'
|
||||
| 'unmute'
|
||||
| 'mark-unread'
|
||||
| 'leave'
|
||||
| 'block'
|
||||
| 'unblock'
|
||||
|
||||
export function runTelegramConversationAction(
|
||||
id: string,
|
||||
action: TelegramConversationAction,
|
||||
options: { seconds?: number } = {},
|
||||
) {
|
||||
return http.post<Conversation | { success: true; deleted: true }>(
|
||||
`/api/conversations/${id}/actions`,
|
||||
{ action, ...options },
|
||||
)
|
||||
}
|
||||
|
||||
export function openTelegramConversation(payload: {
|
||||
identifier: string
|
||||
targetType: 'user' | 'chat' | 'invite'
|
||||
channelId?: string
|
||||
}) {
|
||||
return http.post<{ conversation: Conversation; customer: Customer }>(
|
||||
'/api/conversations/telegram/open',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
+157
-12
@@ -4,26 +4,26 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 14% 96%;
|
||||
--foreground: 215 25% 16%;
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 215 28% 13%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 215 25% 16%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 215 25% 16%;
|
||||
--primary: 172 59% 30%;
|
||||
--primary: 221 83% 53%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 210 16% 93%;
|
||||
--secondary: 210 20% 96%;
|
||||
--secondary-foreground: 215 20% 24%;
|
||||
--muted: 210 16% 93%;
|
||||
--muted-foreground: 215 12% 46%;
|
||||
--accent: 170 40% 93%;
|
||||
--accent-foreground: 172 60% 22%;
|
||||
--muted: 210 20% 96%;
|
||||
--muted-foreground: 215 12% 43%;
|
||||
--accent: 214 100% 97%;
|
||||
--accent-foreground: 221 83% 40%;
|
||||
--destructive: 0 70% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 214 15% 88%;
|
||||
--input: 214 15% 84%;
|
||||
--ring: 172 59% 34%;
|
||||
--radius: 0.5rem;
|
||||
--border: 214 22% 91%;
|
||||
--input: 214 18% 86%;
|
||||
--ring: 221 83% 53%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -40,6 +40,10 @@
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.006em;
|
||||
background-image:
|
||||
radial-gradient(circle at 75% -10%, rgba(37, 99, 235, 0.05), transparent 26rem),
|
||||
linear-gradient(180deg, #ffffff 0, hsl(var(--background)) 22rem);
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -65,6 +69,104 @@
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
.surface-panel {
|
||||
@apply rounded-2xl border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.03),0_10px_30px_rgba(15,23,42,0.025)];
|
||||
}
|
||||
.eyebrow {
|
||||
@apply text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-700;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-wallpaper {
|
||||
transition: background-color .28s ease, background-image .28s ease;
|
||||
background-position: center, center, center, center, 0 0, center;
|
||||
background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%, 320px 320px, 100% 100%;
|
||||
}
|
||||
|
||||
.chat-wallpaper-ocean {
|
||||
background-color: #eaf2f9;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 8% 10%, rgba(125, 211, 252, .42), transparent 45%),
|
||||
radial-gradient(ellipse at 92% 8%, rgba(96, 165, 250, .34), transparent 44%),
|
||||
radial-gradient(ellipse at 12% 92%, rgba(45, 212, 191, .24), transparent 43%),
|
||||
radial-gradient(ellipse at 90% 90%, rgba(129, 140, 248, .25), transparent 46%),
|
||||
url('/chat-pattern.svg'),
|
||||
linear-gradient(145deg, rgba(245, 251, 255, .88), rgba(224, 236, 250, .94));
|
||||
}
|
||||
|
||||
.chat-wallpaper-aurora {
|
||||
background-color: #eff0ff;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 8% 8%, rgba(167, 139, 250, .38), transparent 46%),
|
||||
radial-gradient(ellipse at 92% 12%, rgba(244, 114, 182, .3), transparent 43%),
|
||||
radial-gradient(ellipse at 10% 92%, rgba(34, 211, 238, .3), transparent 45%),
|
||||
radial-gradient(ellipse at 90% 90%, rgba(99, 102, 241, .32), transparent 47%),
|
||||
url('/chat-pattern.svg'),
|
||||
linear-gradient(145deg, rgba(249, 244, 255, .9), rgba(228, 246, 255, .88));
|
||||
}
|
||||
|
||||
.chat-wallpaper-sunset {
|
||||
background-color: #fff3ed;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 8% 10%, rgba(251, 191, 36, .34), transparent 44%),
|
||||
radial-gradient(ellipse at 92% 8%, rgba(251, 113, 133, .33), transparent 45%),
|
||||
radial-gradient(ellipse at 9% 92%, rgba(251, 146, 60, .28), transparent 43%),
|
||||
radial-gradient(ellipse at 90% 90%, rgba(192, 132, 252, .28), transparent 47%),
|
||||
url('/chat-pattern.svg'),
|
||||
linear-gradient(145deg, rgba(255, 249, 232, .92), rgba(250, 235, 255, .9));
|
||||
}
|
||||
|
||||
.chat-wallpaper-mint {
|
||||
background-color: #eaf8f4;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 8% 9%, rgba(52, 211, 153, .34), transparent 46%),
|
||||
radial-gradient(ellipse at 92% 10%, rgba(34, 211, 238, .28), transparent 44%),
|
||||
radial-gradient(ellipse at 10% 92%, rgba(163, 230, 53, .22), transparent 43%),
|
||||
radial-gradient(ellipse at 90% 90%, rgba(96, 165, 250, .3), transparent 47%),
|
||||
url('/chat-pattern.svg'),
|
||||
linear-gradient(145deg, rgba(237, 253, 247, .92), rgba(229, 244, 255, .9));
|
||||
}
|
||||
|
||||
.telegram-bubble-in {
|
||||
border: 1px solid rgba(226, 232, 240, .96);
|
||||
background: rgba(255, 255, 255, .97);
|
||||
box-shadow: 0 2px 5px rgba(15, 23, 42, .06), 0 10px 24px -18px rgba(15, 23, 42, .22);
|
||||
}
|
||||
|
||||
.telegram-bubble-in::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: -7px;
|
||||
width: 12px;
|
||||
height: 14px;
|
||||
background: rgba(255, 255, 255, .97);
|
||||
clip-path: polygon(100% 0, 0 100%, 100% 100%);
|
||||
}
|
||||
|
||||
.telegram-bubble-out {
|
||||
border: 1px solid rgba(147, 197, 253, .75);
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #e0f2fe 52%, #eff6ff 100%);
|
||||
box-shadow: 0 3px 7px rgba(37, 99, 235, .1), 0 12px 28px -18px rgba(37, 99, 235, .38);
|
||||
}
|
||||
|
||||
.telegram-bubble-out::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -7px;
|
||||
bottom: -1px;
|
||||
width: 12px;
|
||||
height: 14px;
|
||||
background: #e8f3ff;
|
||||
clip-path: polygon(0 0, 0 100%, 100% 100%);
|
||||
}
|
||||
|
||||
.group:hover .telegram-bubble-in {
|
||||
box-shadow: 0 3px 8px rgba(15, 23, 42, .08), 0 14px 30px -20px rgba(15, 23, 42, .28);
|
||||
}
|
||||
|
||||
.group:hover .telegram-bubble-out {
|
||||
box-shadow: 0 4px 10px rgba(37, 99, 235, .13), 0 16px 32px -20px rgba(37, 99, 235, .45);
|
||||
}
|
||||
|
||||
@keyframes message-in-left {
|
||||
@@ -85,12 +187,55 @@
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes conversation-select {
|
||||
0% {
|
||||
transform: translate3d(0, 2px, 0) scale(.988);
|
||||
box-shadow: 0 0 0 rgba(37, 99, 235, 0);
|
||||
}
|
||||
58% {
|
||||
transform: translate3d(0, -1px, 0) scale(1.003);
|
||||
box-shadow: 0 12px 28px -15px rgba(37, 99, 235, .48);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0) scale(1);
|
||||
box-shadow: 0 9px 24px -15px rgba(37, 99, 235, .52), 0 0 0 1px rgba(96, 165, 250, .1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes conversation-sheen {
|
||||
from { transform: translate3d(-170%, 0, 0) skewX(-18deg); opacity: 0; }
|
||||
18% { opacity: .7; }
|
||||
to { transform: translate3d(470%, 0, 0) skewX(-18deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.conversation-selected {
|
||||
animation: conversation-select .42s cubic-bezier(.2, .82, .2, 1) both;
|
||||
}
|
||||
|
||||
.conversation-selected::after {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: 24%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .78), transparent);
|
||||
animation: conversation-sheen .62s cubic-bezier(.2, .7, .2, 1) .04s both;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.animate-message-in-left { animation: message-in-left .34s cubic-bezier(.2,.8,.2,1) both; }
|
||||
.animate-message-in-right { animation: message-in-right .34s cubic-bezier(.2,.8,.2,1) both; }
|
||||
.animate-incoming-notice { animation: incoming-notice .42s cubic-bezier(.2,.9,.2,1) both; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.conversation-selected,
|
||||
.conversation-selected::after {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* RTL 微调 */
|
||||
[dir='rtl'] .rtl-flip {
|
||||
transform: scaleX(-1);
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface AiReplySuggestion {
|
||||
id: string
|
||||
style: ReplyStyle
|
||||
content: string
|
||||
translationCn: string
|
||||
targetLang: string
|
||||
reason: string
|
||||
knowledgeRefs: string[]
|
||||
@@ -40,6 +41,18 @@ export interface TranslateResult {
|
||||
translated: string
|
||||
detectedLang: string
|
||||
terms: { source: string; target: string }[]
|
||||
historyItem?: TranslationHistoryItem
|
||||
}
|
||||
|
||||
export interface TranslationHistoryItem {
|
||||
id: string
|
||||
sourceLang: string
|
||||
targetLang: string
|
||||
originalText: string
|
||||
translatedText: string
|
||||
detectedLang: string
|
||||
favorite: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type AiTextAction = 'polish' | 'translate' | 'shorten' | 'expand' | 'tone'
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface Customer {
|
||||
telegramUserId?: string
|
||||
avatarUrl?: string
|
||||
telegramPhotoFileId?: number
|
||||
telegramChatType?: string
|
||||
}
|
||||
|
||||
export interface CustomerFilters {
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface Message {
|
||||
type: MessageType
|
||||
originalText: string
|
||||
translatedText?: string
|
||||
/** 发出消息的中文客服备注,不会发送到外部渠道 */
|
||||
translationCn?: string
|
||||
translationStatus?: 'pending' | 'completed' | 'failed'
|
||||
translatedAt?: string
|
||||
originalLang: string
|
||||
@@ -43,6 +45,16 @@ export interface Message {
|
||||
attachmentMimeType?: string
|
||||
attachmentSize?: number
|
||||
stickerEmoji?: string
|
||||
emailUid?: number
|
||||
emailMessageId?: string
|
||||
emailSubject?: string
|
||||
emailAttachments?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
size: number
|
||||
available: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
@@ -58,6 +70,16 @@ export interface Conversation {
|
||||
lastMessage?: Message
|
||||
summary: string
|
||||
updatedAt: string
|
||||
emailSubject?: string
|
||||
channelId?: string
|
||||
telegramChatId?: string
|
||||
telegramChatType?: string
|
||||
isPinned?: boolean
|
||||
archived?: boolean
|
||||
mutedUntil?: string | null
|
||||
markedUnread?: boolean
|
||||
isBlocked?: boolean
|
||||
leftAt?: string
|
||||
}
|
||||
|
||||
export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
|
||||
@@ -72,7 +94,18 @@ export interface ChannelConnection {
|
||||
todayMessages: number
|
||||
errorMessage?: string
|
||||
autoReply: boolean
|
||||
authState?: string
|
||||
defaultOwnerId?: string
|
||||
displayName?: string
|
||||
imapHost?: string
|
||||
imapPort?: number
|
||||
imapSecure?: boolean
|
||||
imapFolder?: string
|
||||
smtpHost?: string
|
||||
smtpPort?: number
|
||||
smtpSecure?: boolean
|
||||
syncDays?: number
|
||||
lastVerifiedAt?: string
|
||||
}
|
||||
|
||||
export interface ChannelSyncLog {
|
||||
@@ -87,6 +120,8 @@ export interface ChannelSyncLog {
|
||||
/** 发送消息请求 */
|
||||
export interface SendMessagePayload {
|
||||
text: string
|
||||
/** 已确认的中文参考译文,仅在内部消息记录中保存 */
|
||||
translationCn?: string
|
||||
replyToId?: string
|
||||
replyPreview?: string
|
||||
type?: MessageType
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ export interface SystemSettings {
|
||||
|
||||
export interface AppNotification {
|
||||
id: string
|
||||
type: 'inquiry' | 'risk' | 'task' | 'channel' | 'system'
|
||||
type: 'inquiry' | 'message' | 'risk' | 'task' | 'channel' | 'system'
|
||||
title: string
|
||||
description: string
|
||||
createdAt: string
|
||||
|
||||
Reference in New Issue
Block a user