feat: release contextual omnichannel customer platform
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { ArrowUpRight, Check, Languages } from 'lucide-react'
|
||||
|
||||
export function BrandMark({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src="/brand-mark.svg" alt="" className="h-9 w-9 rounded-xl shadow-[0_8px_24px_-12px_rgba(37,99,235,.9)]" />
|
||||
{!compact && (
|
||||
<div>
|
||||
<p className="text-[15px] font-semibold tracking-[-0.02em] text-slate-950">灵犀跨境</p>
|
||||
<p className="text-[11px] text-slate-500">跨境客户沟通工作台</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductMotion() {
|
||||
return (
|
||||
<div className="relative mx-auto w-full max-w-[470px]">
|
||||
<div className="absolute -inset-10 rounded-full bg-blue-200/25 blur-3xl" />
|
||||
<div className="relative overflow-hidden rounded-[22px] border border-slate-200/90 bg-white/90 shadow-[0_24px_70px_-38px_rgba(15,23,42,.34)] backdrop-blur">
|
||||
<div className="flex h-12 items-center justify-between border-b border-slate-100 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,.10)]" />
|
||||
<span className="text-xs font-medium text-slate-700">Eternity · Telegram</span>
|
||||
</div>
|
||||
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-[10px] font-medium text-slate-500">
|
||||
双语视图
|
||||
</span>
|
||||
</div>
|
||||
<div className="auth-preview-grid relative min-h-[278px] overflow-hidden p-5">
|
||||
<div className="auth-message auth-message-in max-w-[78%] rounded-2xl rounded-bl-md border border-slate-200 bg-white px-3.5 py-3 shadow-sm">
|
||||
<p className="text-[11px] font-medium text-slate-400">English</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-700">
|
||||
Could you confirm the sample delivery date?
|
||||
</p>
|
||||
</div>
|
||||
<div className="my-4 flex items-center gap-2 pl-3">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full border border-blue-100 bg-blue-50 text-blue-600">
|
||||
<Languages className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="auth-translation-track relative h-px w-24 overflow-visible bg-blue-100">
|
||||
<span className="auth-translation-dot absolute -top-1 h-2 w-2 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">已翻译</span>
|
||||
</div>
|
||||
<div className="auth-message auth-message-out ml-auto max-w-[80%] rounded-2xl rounded-br-md border border-blue-100 bg-blue-50/90 px-3.5 py-3 shadow-sm">
|
||||
<p className="text-[11px] font-medium text-blue-500">中文参考</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-700">可以确认一下样品的交付日期吗?</p>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
['意向', '高'],
|
||||
['情绪', '积极'],
|
||||
['风险', '低'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-xl border border-slate-100 bg-white/75 px-3 py-2.5">
|
||||
<p className="text-[10px] text-slate-400">{label}</p>
|
||||
<p className="mt-0.5 text-xs font-semibold text-slate-700">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-floating-chip absolute -bottom-4 -right-3 flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-lg shadow-slate-900/5">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-50 text-emerald-600">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="text-[11px] font-medium text-slate-600">消息已送达</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AuthShell({
|
||||
children,
|
||||
topAction,
|
||||
heading = '让每一次跨境沟通,都清楚且及时。',
|
||||
description = '汇集消息、翻译、客户资料与团队跟进,在一个工作台完成日常客户沟通。',
|
||||
}: {
|
||||
children: ReactNode
|
||||
topAction?: ReactNode
|
||||
heading?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-slate-900">
|
||||
<div className="grid min-h-screen lg:grid-cols-[minmax(420px,46%)_1fr]">
|
||||
<aside className="relative hidden overflow-hidden border-r border-slate-200 bg-[#f7f9fc] lg:flex lg:flex-col">
|
||||
<div className="auth-corner-glow absolute inset-0" />
|
||||
<div className="relative z-10 flex items-center justify-between px-10 py-8 xl:px-14">
|
||||
<BrandMark />
|
||||
<span className="flex items-center gap-1 text-[11px] font-medium text-slate-400">
|
||||
LINGXI OS
|
||||
<ArrowUpRight className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-1 flex-col justify-center px-10 pb-12 xl:px-14">
|
||||
<div className="mb-10 max-w-[520px]">
|
||||
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-blue-600">GLOBAL COMMUNICATION</p>
|
||||
<h1 className="max-w-[480px] text-[30px] font-semibold leading-[1.28] tracking-[-0.035em] text-slate-950">
|
||||
{heading}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-[460px] text-sm leading-6 text-slate-500">{description}</p>
|
||||
</div>
|
||||
<ProductMotion />
|
||||
</div>
|
||||
<p className="relative z-10 px-10 pb-7 text-[11px] text-slate-400 xl:px-14">
|
||||
© 2026 灵犀跨境
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<main className="relative flex min-h-screen flex-col">
|
||||
<header className="flex h-20 items-center justify-between px-6 sm:px-10 lg:px-12">
|
||||
<div className="lg:hidden"><BrandMark /></div>
|
||||
<div className="ml-auto">{topAction}</div>
|
||||
</header>
|
||||
<div className="flex flex-1 items-center justify-center px-6 pb-16 sm:px-10 lg:px-12">
|
||||
<div className="w-full max-w-[430px]">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,11 +15,11 @@ export function PageHeader({
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('mb-4', className)}>
|
||||
<div className={cn('mb-5', className)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
|
||||
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
|
||||
<h1 className="text-[22px] font-semibold tracking-[-0.035em] text-slate-950 md:text-2xl">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-slate-500">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
|
||||
@@ -29,13 +29,13 @@ export function StatCard({
|
||||
<Card
|
||||
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 && 'cursor-pointer hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_12px_28px_-16px_rgba(37,99,235,.48)]',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-[10px] border border-primary-100 bg-primary-50 transition-colors group-hover:bg-primary-100">
|
||||
<Icon className="h-4 w-4 text-primary-600" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -20,17 +20,19 @@ export function AiReplies({
|
||||
onAdopt,
|
||||
onEdit,
|
||||
onClose,
|
||||
targetLanguage = 'auto',
|
||||
}: {
|
||||
conversationId: string
|
||||
onAdopt: (content: string, translationCn: string) => void
|
||||
onAdopt: (content: string, translationCn: string, suggestionId: string) => void
|
||||
onEdit: (content: string, translationCn: string) => void
|
||||
onClose: () => void
|
||||
targetLanguage?: string
|
||||
}) {
|
||||
const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
|
||||
useEffect(() => setItems(null), [conversationId])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => generateReplies(conversationId),
|
||||
mutationFn: () => generateReplies(conversationId, targetLanguage),
|
||||
onSuccess: (data) => {
|
||||
setItems(Array.isArray(data) ? data : [])
|
||||
toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复')
|
||||
@@ -98,7 +100,7 @@ export function AiReplies({
|
||||
<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
|
||||
发送给客户 · {languageName(s.targetLang)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-800">{s.content}</p>
|
||||
</div>
|
||||
@@ -145,7 +147,7 @@ export function AiReplies({
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button size="sm" variant="default" onClick={() => onAdopt(s.content, s.translationCn)}>
|
||||
<Button size="sm" variant="default" onClick={() => onAdopt(s.content, s.translationCn, s.id)}>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
采用
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Shrink,
|
||||
Expand,
|
||||
ChevronDown,
|
||||
Languages,
|
||||
Reply,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
@@ -20,6 +21,7 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toast } from '@/app/store/toast'
|
||||
import type { ChannelType } from '@/types'
|
||||
import { LANGUAGES, languageName } from '@/config/languages'
|
||||
|
||||
const QUICK_PHRASES = [
|
||||
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' },
|
||||
@@ -48,8 +50,11 @@ export function Composer({
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
channel = 'telegram',
|
||||
targetLanguage = 'auto',
|
||||
detectedLanguage,
|
||||
onTargetLanguageChange,
|
||||
}: {
|
||||
onSend: (text: string, autoTranslate?: boolean) => Promise<void>
|
||||
onSend: (text: string, autoTranslate?: boolean, targetLang?: string) => Promise<void>
|
||||
onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise<void>
|
||||
value?: string
|
||||
onChange?: (text: string) => void
|
||||
@@ -57,6 +62,9 @@ export function Composer({
|
||||
replyingTo?: { id: string; text: string } | null
|
||||
onCancelReply?: () => void
|
||||
channel?: ChannelType
|
||||
targetLanguage?: string
|
||||
detectedLanguage?: string
|
||||
onTargetLanguageChange?: (language: string) => void
|
||||
}) {
|
||||
const [internalText, setInternalText] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
@@ -92,7 +100,7 @@ export function Composer({
|
||||
if (!text.trim()) return
|
||||
setSending(true)
|
||||
try {
|
||||
await onSend(text, autoTranslate)
|
||||
await onSend(text, autoTranslate, targetLanguage)
|
||||
setText('')
|
||||
} finally {
|
||||
setSending(false)
|
||||
@@ -116,7 +124,7 @@ export function Composer({
|
||||
setAiLoading(true)
|
||||
try {
|
||||
const { textAction } = await import('@/services/api/ai')
|
||||
const res = await textAction({ text, action: action as any, tone, targetLang: 'en' })
|
||||
const res = await textAction({ text, action: action as any, tone, targetLang: targetLanguage })
|
||||
setText(res.result)
|
||||
toast.success('AI 处理完成')
|
||||
} catch {
|
||||
@@ -238,11 +246,31 @@ export function Composer({
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Switch checked={autoTranslate} onCheckedChange={setAutoTranslate} />
|
||||
中文自动译为英文{channel === 'email' ? '邮件' : '发送'}
|
||||
双向翻译
|
||||
</label>
|
||||
{autoTranslate && (
|
||||
<label className="relative flex items-center">
|
||||
<Languages className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-primary" />
|
||||
<select
|
||||
value={targetLanguage}
|
||||
onChange={(event) => onTargetLanguageChange?.(event.target.value)}
|
||||
aria-label="客户目标语言"
|
||||
className="h-8 max-w-[190px] appearance-none rounded-lg border border-primary-100 bg-primary-50/70 py-1 pl-8 pr-7 text-xs font-medium text-slate-700 outline-none transition hover:border-primary-200 focus:border-primary-300 focus:ring-2 focus:ring-primary-100"
|
||||
>
|
||||
{LANGUAGES.map((language) => (
|
||||
<option key={language.code} value={language.code}>
|
||||
{language.code === 'auto' && detectedLanguage
|
||||
? `自动 · ${languageName(detectedLanguage)}`
|
||||
: `${language.name} · ${language.nativeName}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 h-3 w-3 text-slate-400" />
|
||||
</label>
|
||||
)}
|
||||
{aiLoading && (
|
||||
<Badge variant="primary" className="animate-pulse">
|
||||
正在调用 AI…
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Brain, ChevronRight } from 'lucide-react'
|
||||
import type { Conversation, Customer, CustomerStage } from '@/types'
|
||||
import { Brain, ChevronRight, LoaderCircle } from 'lucide-react'
|
||||
import type { AiBackfillStatus, Conversation, Customer, CustomerStage } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CustomerStageBadge,
|
||||
@@ -17,10 +17,12 @@ export function ConversationInsightBar({
|
||||
customer,
|
||||
conversation,
|
||||
onOpenProfile,
|
||||
aiBackfill,
|
||||
}: {
|
||||
customer: Customer
|
||||
conversation: Conversation
|
||||
onOpenProfile: () => void
|
||||
aiBackfill?: AiBackfillStatus
|
||||
}) {
|
||||
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
|
||||
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像'
|
||||
@@ -41,6 +43,15 @@ export function ConversationInsightBar({
|
||||
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
||||
{aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && (
|
||||
<span
|
||||
title={`历史消息后台分析:${aiBackfill.completedCount}/${aiBackfill.totalCount}`}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700"
|
||||
>
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
|
||||
</span>
|
||||
@@ -54,6 +65,12 @@ export function ConversationInsightBar({
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 overflow-x-auto pb-0.5 md:hidden">
|
||||
{aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700">
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<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} />
|
||||
|
||||
@@ -46,9 +46,17 @@ export function InsightPanel({
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
|
||||
const c = countryMeta(customer.country)
|
||||
const localTime = new Date(
|
||||
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
|
||||
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
const localTime = Number.isFinite(c.tzOffset)
|
||||
? new Date(
|
||||
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
|
||||
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
const countrySourceLabel = {
|
||||
manual: '人工确认',
|
||||
'account-profile': '账户资料',
|
||||
'phone-prefix': '手机号区号',
|
||||
unknown: '待确认',
|
||||
}[customer.countrySource || 'unknown']
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (data: Partial<Customer>) => updateCustomer(customer.id, data),
|
||||
@@ -114,11 +122,14 @@ export function InsightPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<CountryFlag code={customer.country} />
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{countrySourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{localTime && <div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">当地时间:{localTime}</span>
|
||||
</div>
|
||||
</div>}
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -177,6 +188,51 @@ export function InsightPanel({
|
||||
{/* AI 分析 */}
|
||||
<div className="space-y-4 border-b border-slate-100 p-3">
|
||||
<CustomerProfileRadar customer={customer} compact />
|
||||
<div className="rounded-2xl border border-blue-100 bg-gradient-to-br from-white to-blue-50/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">会话长期记忆</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">
|
||||
{conversation.aiContextStatus === 'compressing'
|
||||
? '正在压缩较早的会话内容…'
|
||||
: conversation.aiContextMemory
|
||||
? `已压缩 ${conversation.aiContextSourceMessageCount || conversation.aiContextMemory.sourceMessageCount || 0} 条较早消息`
|
||||
: '会话达到长度阈值后自动生成'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'rounded-full px-2 py-1 text-[10px] font-medium',
|
||||
conversation.aiContextStatus === 'failed'
|
||||
? 'bg-red-50 text-red-600'
|
||||
: conversation.aiContextMemory
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-500',
|
||||
)}>
|
||||
{conversation.aiContextStatus === 'compressing'
|
||||
? '压缩中'
|
||||
: conversation.aiContextMemory
|
||||
? '已启用'
|
||||
: '等待触发'}
|
||||
</span>
|
||||
</div>
|
||||
{conversation.aiContextMemory?.summary && (
|
||||
<p className="mt-3 text-xs leading-5 text-slate-600">
|
||||
{conversation.aiContextMemory.summary}
|
||||
</p>
|
||||
)}
|
||||
{conversation.aiContextMemory?.unresolved?.length ? (
|
||||
<div className="mt-3 border-t border-blue-100 pt-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-blue-600">未决事项</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{conversation.aiContextMemory.unresolved.slice(0, 4).map((item) => (
|
||||
<span key={item} className="rounded-full bg-white px-2 py-1 text-[10px] text-slate-600 shadow-sm">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
LoaderCircle,
|
||||
SmilePlus,
|
||||
Trash2,
|
||||
Brain,
|
||||
} from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ChannelType, Message } from '@/types'
|
||||
@@ -89,6 +90,7 @@ export function MessageBubble({
|
||||
customerName,
|
||||
customerAvatar,
|
||||
channel,
|
||||
showIntent = true,
|
||||
}: {
|
||||
msg: Message
|
||||
viewMode: ViewMode
|
||||
@@ -102,6 +104,7 @@ export function MessageBubble({
|
||||
customerName?: string
|
||||
customerAvatar?: string
|
||||
channel?: ChannelType
|
||||
showIntent?: boolean
|
||||
}) {
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
const [translationExpanded, setTranslationExpanded] = useState(false)
|
||||
@@ -124,7 +127,15 @@ export function MessageBubble({
|
||||
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 targetLanguage = msg.targetLang && !['und', 'auto', 'unknown'].includes(msg.targetLang)
|
||||
? languageName(msg.targetLang)
|
||||
: '客户语言'
|
||||
const providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram'
|
||||
const contextualTranslation = msg.translationMethod === 'ai-contextual'
|
||||
&& Number(msg.translationContextMessages || 0) > 0
|
||||
const translationTrace = contextualTranslation
|
||||
? `AI 结合最近 ${msg.translationContextMessages} 条会话消息${msg.translationConversationSummaryApplied ? '及客户画像摘要' : ''}完成翻译`
|
||||
: 'AI 直接翻译当前消息'
|
||||
const isMediaMessage = ['image', 'audio', 'file'].includes(msg.type)
|
||||
const hasProviderAttachment = Boolean(
|
||||
msg.telegramFileId
|
||||
@@ -219,7 +230,7 @@ export function MessageBubble({
|
||||
<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 ? '英文译文' : '原文'}
|
||||
客户收到 · {translatedText ? targetLanguage : '原文'}
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
|
||||
</div>
|
||||
@@ -265,9 +276,12 @@ export function MessageBubble({
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
<span
|
||||
title={translationTrace}
|
||||
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 ? '发送内容 · 英文' : 'AI 译文 · 中文'}
|
||||
{isMe ? `发送内容 · ${targetLanguage}` : contextualTranslation ? 'AI 语境译文 · 中文' : 'AI 译文 · 中文'}
|
||||
</span>
|
||||
<div className="mt-1.5">{translationContent()}</div>
|
||||
</div>
|
||||
@@ -277,9 +291,12 @@ export function MessageBubble({
|
||||
if (viewMode === 'translated' && translatedText) {
|
||||
return (
|
||||
<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">
|
||||
<span
|
||||
title={translationTrace}
|
||||
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 ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`}
|
||||
{isMe ? `${sourceLanguage} → ${targetLanguage}` : `${sourceLanguage} → 中文`}
|
||||
</span>
|
||||
<div className="mt-1.5">{translationContent(false)}</div>
|
||||
</div>
|
||||
@@ -377,6 +394,31 @@ export function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
{renderContent()}
|
||||
{!isMe && showIntent && msg.intentAnalysis && (
|
||||
<div
|
||||
data-testid="message-intent"
|
||||
title={msg.intentAnalysis.rationale}
|
||||
className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-slate-200/70 pt-2"
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold text-violet-700">
|
||||
<Brain className="h-3 w-3" />
|
||||
意图 · {msg.intentAnalysis.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
可信度 {Math.round(Number(msg.intentAnalysis.confidence || 0) * 100)}%
|
||||
</span>
|
||||
{['high', 'urgent'].includes(msg.intentAnalysis.urgency) && (
|
||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
|
||||
{msg.intentAnalysis.urgency === 'urgent' ? '紧急' : '高优先级'}
|
||||
</span>
|
||||
)}
|
||||
{msg.intentAnalysis.entities?.slice(0, 3).map((entity) => (
|
||||
<span key={entity} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
|
||||
{entity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasProviderAttachment && attachmentLoading && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded-lg bg-muted/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 {providerName} 文件…
|
||||
|
||||
@@ -9,7 +9,7 @@ export function AppLayout() {
|
||||
const { mobileNavOpen, setMobileNavOpen } = useUiStore()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<div className="flex h-screen overflow-hidden bg-[#f7f9fc]">
|
||||
<GlobalRealtime />
|
||||
{/* 桌面端固定侧边栏 */}
|
||||
<div className="hidden shrink-0 lg:block">
|
||||
@@ -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 bg-white/35 scrollbar-thin" id="main-scroll">
|
||||
<main className="flex-1 overflow-y-auto bg-[#f7f9fc] scrollbar-thin" id="main-scroll">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { PanelLeftClose, PanelLeftOpen, Sparkles } from 'lucide-react'
|
||||
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||
import { NAV_ITEMS } from '@/config/navigation'
|
||||
import { useUiStore } from '@/app/store/ui'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
@@ -35,18 +35,16 @@ export function Sidebar() {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'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]',
|
||||
'relative z-30 flex h-full flex-col border-e border-slate-200 bg-white shadow-[6px_0_24px_-22px_rgba(15,23,42,.45)] transition-[width] duration-200',
|
||||
sidebarCollapsed ? 'w-[68px]' : 'w-[236px]',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="flex h-16 cursor-pointer items-center gap-2.5 px-4"
|
||||
className="flex h-16 cursor-pointer items-center gap-2.5 border-b border-slate-100 px-4"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
<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>
|
||||
<img src="/brand-mark.svg" alt="" className="h-9 w-9 shrink-0 rounded-xl shadow-sm shadow-primary-900/10" />
|
||||
{!sidebarCollapsed && (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-[15px] font-semibold tracking-tight leading-tight">{t('app.name')}</p>
|
||||
@@ -56,7 +54,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-2 scrollbar-thin">
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-3 scrollbar-thin">
|
||||
{groups.map((group) => {
|
||||
const groupItems = items.filter((item) => item.group === group.key)
|
||||
if (!groupItems.length) return null
|
||||
@@ -76,10 +74,10 @@ export function Sidebar() {
|
||||
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-[13px] transition-all duration-150',
|
||||
'group relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 text-[13px] transition-all duration-150',
|
||||
isActive
|
||||
? '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',
|
||||
? 'bg-primary-50 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.1)] before:absolute before:inset-y-2 before:left-0 before:w-[3px] before:rounded-r-full before:bg-primary-600'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)
|
||||
}
|
||||
@@ -96,8 +94,8 @@ export function Sidebar() {
|
||||
|
||||
{/* 底部:套餐与企业信息 */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="rounded-md bg-muted/60 p-3">
|
||||
<div className="border-t border-slate-100 p-3">
|
||||
<div className="rounded-[12px] border border-slate-200 bg-slate-50/80 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{t('sidebar.currentPlan')}</span>
|
||||
<span className="text-xs font-medium text-primary-700">{plan.name}</span>
|
||||
|
||||
@@ -62,10 +62,10 @@ export function Topbar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<header className="relative z-20 flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 bg-white/95 px-3 shadow-[0_6px_24px_-24px_rgba(15,23,42,.7)] backdrop-blur-xl md:px-5">
|
||||
{/* 移动端菜单 */}
|
||||
<button
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
|
||||
className="rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 lg:hidden"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
aria-label="打开导航"
|
||||
>
|
||||
@@ -75,7 +75,7 @@ export function Topbar() {
|
||||
{/* 企业切换 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-muted md:flex">
|
||||
<button className="hidden items-center gap-1.5 rounded-[10px] border border-transparent px-2.5 py-1.5 text-sm font-medium hover:border-slate-200 hover:bg-slate-50 md:flex">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="max-w-[180px] truncate">{tenant?.name ?? '—'}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -96,7 +96,7 @@ export function Topbar() {
|
||||
{/* 语言切换 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-1 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('lang.name')}>
|
||||
<button className="flex items-center gap-1 rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground" title={t('lang.name')}>
|
||||
<Globe className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function Topbar() {
|
||||
|
||||
{/* 帮助 */}
|
||||
<button
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground"
|
||||
title={t('topbar.help')}
|
||||
onClick={() => navigate('/help')}
|
||||
>
|
||||
@@ -128,7 +128,7 @@ export function Topbar() {
|
||||
{/* 通知 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('topbar.notifications')}>
|
||||
<button className="relative rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground" title={t('topbar.notifications')}>
|
||||
<Bell className="h-[18px] w-[18px]" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute right-1 top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
|
||||
@@ -172,7 +172,7 @@ export function Topbar() {
|
||||
{/* 用户菜单 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted">
|
||||
<button className="flex items-center gap-2 rounded-[10px] border border-transparent px-1.5 py-1 hover:border-slate-200 hover:bg-slate-50">
|
||||
<Avatar name={user?.name ?? '?'} size="sm" online />
|
||||
<span className="hidden text-start md:block">
|
||||
<span className="block max-w-[90px] truncate text-sm font-medium leading-tight">{user?.name}</span>
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 select-none',
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[10px] text-sm font-medium transition-[color,background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none active:translate-y-px',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary-700 active:bg-primary-800',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-border',
|
||||
outline: 'border border-input bg-card hover:bg-muted',
|
||||
default: 'bg-primary text-primary-foreground shadow-[0_1px_2px_rgba(37,99,235,.22)] hover:bg-primary-700 hover:shadow-[0_6px_16px_-8px_rgba(37,99,235,.65)] active:bg-primary-800',
|
||||
secondary: 'bg-slate-100 text-slate-700 hover:bg-slate-200',
|
||||
outline: 'border border-slate-200 bg-white text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50',
|
||||
ghost: 'hover:bg-muted text-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-red-600',
|
||||
dangerOutline: 'border border-red-200 bg-card text-red-600 hover:bg-red-50',
|
||||
@@ -19,7 +19,7 @@ const buttonVariants = cva(
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
default: 'h-9 px-4',
|
||||
lg: 'h-10 px-5',
|
||||
lg: 'h-11 px-5',
|
||||
icon: 'h-9 w-9',
|
||||
'icon-sm': 'h-7 w-7',
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/utils/cn'
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
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)}
|
||||
className={cn('rounded-[14px] border border-slate-200/90 bg-card shadow-[0_1px_2px_rgba(15,23,42,0.025),0_8px_24px_rgba(15,23,42,0.025)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -21,7 +21,7 @@ export function CardHeader({
|
||||
return (
|
||||
<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 tracking-tight text-foreground">{title}</h3>}
|
||||
{title && <h3 className="text-[14px] font-semibold tracking-[-0.01em] text-slate-900">{title}</h3>}
|
||||
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ export function CardHeader({
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('p-4', className)} {...props} />
|
||||
return <div className={cn('p-5', className)} {...props} />
|
||||
}
|
||||
|
||||
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
|
||||
@@ -5,17 +5,20 @@ export function Switch({
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange?: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
className={cn(
|
||||
|
||||
@@ -10,26 +10,26 @@ export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>)
|
||||
}
|
||||
|
||||
export function THead({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <thead className={cn('border-b border-border bg-muted/50', className)} {...props} />
|
||||
return <thead className={cn('border-b border-slate-200 bg-slate-50/80', className)} {...props} />
|
||||
}
|
||||
|
||||
export function TBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <tbody className={cn('divide-y divide-border', className)} {...props} />
|
||||
return <tbody className={cn('divide-y divide-slate-100', className)} {...props} />
|
||||
}
|
||||
|
||||
export function Tr({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return <tr className={cn('transition-colors hover:bg-muted/40', className)} {...props} />
|
||||
return <tr className={cn('transition-colors hover:bg-primary-50/35', className)} {...props} />
|
||||
}
|
||||
|
||||
export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th
|
||||
className={cn('whitespace-nowrap px-3 py-2.5 text-start text-xs font-medium text-muted-foreground', className)}
|
||||
className={cn('whitespace-nowrap px-4 py-3 text-start text-[11px] font-semibold uppercase tracking-[0.04em] text-slate-500', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Td({ className, colSpan, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return <td className={cn('px-3 py-2.5 align-middle', className)} colSpan={colSpan} {...props} />
|
||||
return <td className={cn('px-4 py-3 align-middle text-slate-700', className)} colSpan={colSpan} {...props} />
|
||||
}
|
||||
|
||||
+12
-1
@@ -26,7 +26,18 @@ export const COUNTRIES: CountryMeta[] = [
|
||||
]
|
||||
|
||||
export function countryMeta(code: string): CountryMeta {
|
||||
return COUNTRIES.find((c) => c.code === code) ?? { code, zh: code, en: code, flag: '🏳️', tzOffset: 0, lang: 'en' }
|
||||
const normalized = String(code || '').trim().toUpperCase()
|
||||
const known = COUNTRIES.find((c) => c.code === normalized)
|
||||
if (known) return known
|
||||
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||
return { code: '', zh: '国家待确认', en: 'Country not confirmed', flag: '🌐', tzOffset: Number.NaN, lang: '' }
|
||||
}
|
||||
const flag = String.fromCodePoint(
|
||||
...[...normalized].map((character) => 127397 + character.charCodeAt(0)),
|
||||
)
|
||||
const zh = new Intl.DisplayNames(['zh-CN'], { type: 'region' }).of(normalized) || normalized
|
||||
const en = new Intl.DisplayNames(['en'], { type: 'region' }).of(normalized) || normalized
|
||||
return { code: normalized, zh, en, flag, tzOffset: Number.NaN, lang: '' }
|
||||
}
|
||||
|
||||
/** 行业 */
|
||||
|
||||
@@ -53,6 +53,49 @@ export const LANGUAGES: LanguageOption[] = [
|
||||
{ code: 'ro', name: '罗马尼亚语', nativeName: 'Română' },
|
||||
{ code: 'cs', name: '捷克语', nativeName: 'Čeština' },
|
||||
{ code: 'el', name: '希腊语', nativeName: 'Ελληνικά' },
|
||||
{ code: 'da', name: '丹麦语', nativeName: 'Dansk' },
|
||||
{ code: 'no', name: '挪威语', nativeName: 'Norsk' },
|
||||
{ code: 'fi', name: '芬兰语', nativeName: 'Suomi' },
|
||||
{ code: 'hu', name: '匈牙利语', nativeName: 'Magyar' },
|
||||
{ code: 'bg', name: '保加利亚语', nativeName: 'Български' },
|
||||
{ code: 'sr', name: '塞尔维亚语', nativeName: 'Српски' },
|
||||
{ code: 'hr', name: '克罗地亚语', nativeName: 'Hrvatski' },
|
||||
{ code: 'sk', name: '斯洛伐克语', nativeName: 'Slovenčina' },
|
||||
{ code: 'sl', name: '斯洛文尼亚语', nativeName: 'Slovenščina' },
|
||||
{ code: 'lt', name: '立陶宛语', nativeName: 'Lietuvių' },
|
||||
{ code: 'lv', name: '拉脱维亚语', nativeName: 'Latviešu' },
|
||||
{ code: 'et', name: '爱沙尼亚语', nativeName: 'Eesti' },
|
||||
{ code: 'ca', name: '加泰罗尼亚语', nativeName: 'Català' },
|
||||
{ code: 'sq', name: '阿尔巴尼亚语', nativeName: 'Shqip' },
|
||||
{ code: 'mk', name: '马其顿语', nativeName: 'Македонски' },
|
||||
{ code: 'ka', name: '格鲁吉亚语', nativeName: 'ქართული' },
|
||||
{ code: 'hy', name: '亚美尼亚语', nativeName: 'Հայերեն' },
|
||||
{ code: 'az', name: '阿塞拜疆语', nativeName: 'Azərbaycanca' },
|
||||
{ code: 'kk', name: '哈萨克语', nativeName: 'Қазақша' },
|
||||
{ code: 'uz', name: '乌兹别克语', nativeName: 'Oʻzbekcha' },
|
||||
{ code: 'mn', name: '蒙古语', nativeName: 'Монгол' },
|
||||
{ code: 'ne', name: '尼泊尔语', nativeName: 'नेपाली' },
|
||||
{ code: 'si', name: '僧伽罗语', nativeName: 'සිංහල' },
|
||||
{ code: 'ta', name: '泰米尔语', nativeName: 'தமிழ்' },
|
||||
{ code: 'te', name: '泰卢固语', nativeName: 'తెలుగు' },
|
||||
{ code: 'mr', name: '马拉地语', nativeName: 'मराठी' },
|
||||
{ code: 'gu', name: '古吉拉特语', nativeName: 'ગુજરાતી' },
|
||||
{ code: 'pa', name: '旁遮普语', nativeName: 'ਪੰਜਾਬੀ' },
|
||||
{ code: 'kn', name: '卡纳达语', nativeName: 'ಕನ್ನಡ' },
|
||||
{ code: 'ml', name: '马拉雅拉姆语', nativeName: 'മലയാളം' },
|
||||
{ code: 'fil', name: '菲律宾语', nativeName: 'Filipino' },
|
||||
{ code: 'km', name: '高棉语', nativeName: 'ខ្មែរ' },
|
||||
{ code: 'lo', name: '老挝语', nativeName: 'ລາວ' },
|
||||
{ code: 'my', name: '缅甸语', nativeName: 'မြန်မာ' },
|
||||
{ code: 'am', name: '阿姆哈拉语', nativeName: 'አማርኛ' },
|
||||
{ code: 'af', name: '南非荷兰语', nativeName: 'Afrikaans' },
|
||||
{ code: 'zu', name: '祖鲁语', nativeName: 'isiZulu' },
|
||||
{ code: 'ha', name: '豪萨语', nativeName: 'Hausa' },
|
||||
{ code: 'yo', name: '约鲁巴语', nativeName: 'Yorùbá' },
|
||||
{ code: 'ig', name: '伊博语', nativeName: 'Igbo' },
|
||||
{ code: 'so', name: '索马里语', nativeName: 'Soomaali' },
|
||||
{ code: 'ps', name: '普什图语', nativeName: 'پښتو', rtl: true },
|
||||
{ code: 'ku', name: '库尔德语', nativeName: 'Kurdî' },
|
||||
]
|
||||
|
||||
export const RTL_LANG_CODES = LANGUAGES.filter((l) => l.rtl).map((l) => l.code)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PageLoading, ErrorState } from '@/components/ui/feedback'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
@@ -8,13 +9,20 @@ import { LiveDataBadge } from '@/components/common/LiveDataBadge'
|
||||
import { CountryFlag } from '@/components/common/CountryFlag'
|
||||
import { ChartCard, TrendChart, DonutChart, FunnelChart, RankBars, SimpleBarChart } from '@/components/charts'
|
||||
import { getAnalytics } from '@/services/api/org'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { Activity, BadgePercent, TrendingUp, Users, Zap, Timer, AlertTriangle, Target } from 'lucide-react'
|
||||
import { Activity, BadgePercent, Users, Zap, Target } from 'lucide-react'
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: 'today', label: '今日' },
|
||||
{ value: '7d', label: '近 7 天' },
|
||||
{ value: '30d', label: '近 30 天' },
|
||||
{ value: 'quarter', label: '本季度' },
|
||||
] as const
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [period, setPeriod] = useState<(typeof PERIOD_OPTIONS)[number]['value']>('30d')
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['analytics'],
|
||||
queryFn: () => getAnalytics(),
|
||||
queryKey: ['analytics', period],
|
||||
queryFn: () => getAnalytics({ period }),
|
||||
})
|
||||
|
||||
if (isLoading) return <PageLoading />
|
||||
@@ -23,21 +31,35 @@ export function AnalyticsPage() {
|
||||
const k = data.kpis
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="page-shell">
|
||||
<PageHeader
|
||||
title="数据分析中心"
|
||||
description="查看客户、消息、跟进与转化数据"
|
||||
actions={<LiveDataBadge />}
|
||||
extra={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{['today', '7d', '30d', 'quarter'].map((p) => (
|
||||
<button key={p} className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:bg-border">{p === '30d' ? '近30天' : p === '7d' ? '近7天' : p === 'today' ? '今日' : '本季度'}</button>
|
||||
))}
|
||||
title="业务分析"
|
||||
description="从客户获取、沟通效率到成交转化,查看完整经营链路"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<LiveDataBadge />
|
||||
<div className="flex rounded-[10px] border border-slate-200 bg-white p-1 shadow-sm">
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={period === option.value}
|
||||
onClick={() => setPeriod(option.value)}
|
||||
className={`rounded-[7px] px-3 py-1.5 text-xs font-medium transition ${
|
||||
period === option.value
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-5">
|
||||
<StatCard title="新增客户" value={k.newCustomers.value} delta={k.newCustomers.delta} icon={Users} />
|
||||
<StatCard title="有效询盘" value={k.validInquiries.value} delta={k.validInquiries.delta} icon={Activity} />
|
||||
<StatCard title="高意向客户" value={k.highIntent.value} delta={k.highIntent.delta} icon={Target} />
|
||||
@@ -45,27 +67,62 @@ export function AnalyticsPage() {
|
||||
<StatCard title="AI采纳率" value={`${k.aiAdoptionRate.value}%`} delta={k.aiAdoptionRate.delta} icon={Zap} />
|
||||
</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: '#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>
|
||||
<ChartCard title="意向分数分布" height={260}><DonutChart data={data.intentDistribution} nameKey="range" /></ChartCard>
|
||||
<ChartCard title="产品关注度排行" height={260}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
|
||||
<ChartCard title="团队回复效率排行" height={240}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
|
||||
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
|
||||
</div>
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 className="section-title">增长与转化</h2>
|
||||
<p className="section-description">观察客户增长速度、渠道贡献和阶段流失</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||
<ChartCard className="xl:col-span-7" title="客户增长趋势" description="所选周期内的新增客户变化" height={290}>
|
||||
<TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#2563eb' }]} />
|
||||
</ChartCard>
|
||||
<ChartCard className="xl:col-span-5" title="客户阶段转化漏斗" description="识别转化链路中的主要流失阶段" height={290}>
|
||||
<FunnelChart data={data.funnel} />
|
||||
</ChartCard>
|
||||
<ChartCard className="xl:col-span-4" title="各渠道询盘数量" height={255}>
|
||||
<SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" />
|
||||
</ChartCard>
|
||||
<ChartCard className="xl:col-span-4" title="客户国家/地区分布" height={255}>
|
||||
<DonutChart data={data.countryDistribution} />
|
||||
</ChartCard>
|
||||
<ChartCard className="xl:col-span-4" title="意向分数分布" height={255}>
|
||||
<DonutChart data={data.intentDistribution} nameKey="range" />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 业务概览 */}
|
||||
<h2 className="mt-6 mb-3 text-lg font-semibold">业务概览 <LiveDataBadge className="align-middle" /></h2>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card><CardHeader title="重点跟进客户" /><div className="divide-y divide-border">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground">{c.reason.slice(0, 20)}…</span></div>))}</div></Card>
|
||||
<Card><CardHeader title="逾期跟进" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
|
||||
<Card><CardHeader title="客户增长较快的国家" /><div className="divide-y divide-border">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-4 py-2.5 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
|
||||
<Card><CardHeader title="渠道转化" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
|
||||
<Card><CardHeader title="产品询盘" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
|
||||
<Card><CardHeader title="久未联系的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
|
||||
</div>
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 className="section-title">产品与团队效率</h2>
|
||||
<p className="section-description">比较产品热度、响应速度和团队转化结果</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<ChartCard title="产品关注度排行" height={250}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
|
||||
<ChartCard title="团队回复效率" height={250}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
|
||||
<ChartCard title="团队客户转化" height={250}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 className="section-title">经营提醒</h2>
|
||||
<p className="section-description">需要优先处理的客户、跟进和市场变化</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card><CardHeader title="重点跟进客户" /><div className="divide-y divide-slate-100">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-5 py-3 text-sm"><span className="font-medium text-slate-900">{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="max-w-[55%] truncate text-xs text-slate-500">{c.reason}</span></div>))}</div></Card>
|
||||
<Card><CardHeader title="逾期跟进" /><div className="divide-y divide-slate-100">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-5 py-3 text-sm"><span className="font-medium text-slate-900">{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
|
||||
<Card><CardHeader title="增长较快的市场" /><div className="divide-y divide-slate-100">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-5 py-3 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
|
||||
<Card><CardHeader title="渠道转化" /><div className="px-5 py-6"><p className="text-2xl font-semibold tracking-tight text-slate-950">{data.bossBoard.bestChannel.name}</p><p className="mt-1 text-sm text-slate-500">当前转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
|
||||
<Card><CardHeader title="产品询盘" /><div className="divide-y divide-slate-100 px-5">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between py-3 text-sm"><span className="font-medium text-slate-900">{p.product}</span><span className="text-slate-500">{p.inquiries} 次</span></div>))}</div></Card>
|
||||
<Card><CardHeader title="久未联系的高意向客户" /><div className="divide-y divide-slate-100">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-5 py-3 text-sm"><span className="font-medium text-slate-900">{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="ml-2 text-xs text-slate-500">{c.daysSilent} 天未联系 · 意向 {c.intentScore} 分</span></div>))}</div></Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+98
-165
@@ -3,7 +3,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Sparkles, MessageCircle, Languages, ShieldCheck, UsersRound } from 'lucide-react'
|
||||
import { Globe, LockKeyhole, UserRound } from 'lucide-react'
|
||||
import { login } from '@/services/api/auth'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -11,37 +11,36 @@ import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input, Field } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Tabs } from '@/components/ui/tabs'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { UI_LOCALES, type UiLocale } from '@/config/languages'
|
||||
import { Dropdown } from '@/components/ui/dropdown'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { AuthShell } from '@/components/auth/AuthShell'
|
||||
import { UI_LOCALES, type UiLocale } from '@/config/languages'
|
||||
|
||||
const schema = z.object({
|
||||
account: z.string().min(4, '请输入正确的手机号或邮箱'),
|
||||
password: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function LoginPage() {
|
||||
const { t, locale, setLocale } = useI18n()
|
||||
const { locale, setLocale } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const setSession = useAuthStore((s) => s.setSession)
|
||||
const [mode, setMode] = useState<'code' | 'password'>('password')
|
||||
const setSession = useAuthStore((state) => state.setSession)
|
||||
const [remember, setRemember] = useState(true)
|
||||
const [agreed, setAgreed] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(0)
|
||||
const [agreementOpen, setAgreementOpen] = useState<'privacy' | 'terms' | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } })
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { account: '', password: '' },
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('reason') !== 'session-expired') return
|
||||
@@ -49,179 +48,113 @@ export function LoginPage() {
|
||||
setSearchParams({}, { replace: true })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const doLogin = async (values: FormValues) => {
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (!agreed) {
|
||||
toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
|
||||
return
|
||||
}
|
||||
if (mode === 'password' && !values.password) {
|
||||
toast.warning('请输入密码')
|
||||
return
|
||||
}
|
||||
if (mode === 'code' && (values.code ?? '').length !== 6) {
|
||||
toast.warning('请输入6位验证码')
|
||||
toast.warning('请先阅读并同意隐私政策和服务协议')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const session = await login({ ...values, mode, remember })
|
||||
const session = await login({ ...values, mode: 'password', remember })
|
||||
setSession(session)
|
||||
toast.success(`欢迎回来,${session.user.name}`)
|
||||
navigate('/dashboard')
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e))
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sendCode = () => {
|
||||
setCodeSent(60)
|
||||
const timer = setInterval(() => {
|
||||
setCodeSent((s) => {
|
||||
if (s <= 1) clearInterval(timer)
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
toast.success('验证码已发送')
|
||||
}
|
||||
const localeMenu = (
|
||||
<Dropdown
|
||||
trigger={(
|
||||
<button className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{UI_LOCALES.find((item) => item.code === locale)?.nativeName}
|
||||
</button>
|
||||
)}
|
||||
items={UI_LOCALES.map((item) => ({
|
||||
key: item.code,
|
||||
label: item.nativeName,
|
||||
onSelect: () => setLocale(item.code as UiLocale),
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* 品牌区 */}
|
||||
<div className="hidden w-[46%] flex-col justify-between bg-primary-900 p-10 text-primary-50 lg:flex">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/10">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<AuthShell topAction={localeMenu}>
|
||||
<div className="mb-8">
|
||||
<p className="text-xs font-semibold tracking-[0.12em] text-blue-600">欢迎回来</p>
|
||||
<h2 className="mt-3 text-[28px] font-semibold tracking-[-0.035em] text-slate-950">登录工作台</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">继续处理客户消息与团队跟进。</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Field label="手机号或邮箱" error={errors.account?.message}>
|
||||
<div className="relative">
|
||||
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
className="h-11 rounded-xl border-slate-200 bg-white pl-10 focus-visible:ring-blue-100"
|
||||
placeholder="请输入手机号或邮箱"
|
||||
autoComplete="username"
|
||||
{...register('account')}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="密码" error={errors.password?.message}>
|
||||
<div className="relative">
|
||||
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
className="h-11 rounded-xl border-slate-200 bg-white pl-10 focus-visible:ring-blue-100"
|
||||
type="password"
|
||||
placeholder="请输入登录密码"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Checkbox label="保持登录" checked={remember} onCheckedChange={setRemember} />
|
||||
<span className="text-xs text-slate-400">安全会话 · 12 小时</span>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="h-11 w-full rounded-xl shadow-[0_10px_24px_-14px_rgba(37,99,235,.8)]" loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
还没有账号?{' '}
|
||||
<Link to="/register" className="font-medium text-blue-600 hover:text-blue-700">
|
||||
创建企业
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 border-t border-slate-100 pt-5">
|
||||
<label className="flex items-start gap-2 text-xs leading-5 text-slate-400">
|
||||
<Checkbox checked={agreed} onCheckedChange={setAgreed} className="mt-0.5" />
|
||||
<span>
|
||||
登录即表示同意
|
||||
<button type="button" className="mx-1 text-slate-600 hover:text-blue-600" onClick={() => setAgreementOpen('privacy')}>隐私政策</button>
|
||||
和
|
||||
<button type="button" className="ml-1 text-slate-600 hover:text-blue-600" onClick={() => setAgreementOpen('terms')}>服务协议</button>
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-base font-semibold">{t('app.name')}</p>
|
||||
<p className="text-xs text-primary-200">{t('app.slogan')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-2xl font-semibold leading-snug">
|
||||
集中处理客户消息,
|
||||
<br />
|
||||
更快回复和跟进。
|
||||
</h1>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ icon: MessageCircle, text: '集中处理 WhatsApp、Telegram、邮件和独立站消息' },
|
||||
{ icon: Languages, text: '消息翻译与双语对照,支持行业术语' },
|
||||
{ icon: ShieldCheck, text: '识别客户情绪、沟通意图和风险信号' },
|
||||
{ icon: UsersRound, text: '管理客户资料、意向评分和团队跟进' },
|
||||
].map((f) => (
|
||||
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-white/10">
|
||||
<f.icon className="h-4 w-4" />
|
||||
</span>
|
||||
{f.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-primary-300">© 2026 {t('app.company')}</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 表单区 */}
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="w-full max-w-[400px]">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 lg:hidden">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="font-semibold">{t('app.name')}</span>
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted">
|
||||
<Globe className="h-4 w-4" />
|
||||
{UI_LOCALES.find((l) => l.code === locale)?.nativeName}
|
||||
</button>
|
||||
}
|
||||
items={UI_LOCALES.map((l) => ({
|
||||
key: l.code,
|
||||
label: l.nativeName,
|
||||
onSelect: () => setLocale(l.code as UiLocale),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold">{t('login.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('login.subtitle')}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as 'code' | 'password')}
|
||||
items={[{ value: 'password', label: t('login.passwordTab') }]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className="mt-5 space-y-4" onSubmit={handleSubmit((v) => doLogin(v))}>
|
||||
<Field error={errors.account?.message}>
|
||||
<Input placeholder={t('login.accountPlaceholder')} autoComplete="username" {...register('account')} />
|
||||
</Field>
|
||||
{mode === 'code' ? (
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder={t('login.codePlaceholder')} maxLength={6} {...register('code')} />
|
||||
<Button type="button" variant="outline" className="shrink-0" disabled={codeSent > 0} onClick={sendCode}>
|
||||
{codeSent > 0 ? `${codeSent}s` : t('login.getCode')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Field error={errors.password?.message}>
|
||||
<Input type="password" placeholder={t('login.passwordPlaceholder')} autoComplete="current-password" {...register('password')} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<Checkbox label={t('login.remember')} checked={remember} onCheckedChange={setRemember} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" loading={loading}>
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
还没有账号?{' '}
|
||||
<Link to="/register" className="font-medium text-primary hover:underline">
|
||||
{t('login.register')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onCheckedChange={setAgreed}
|
||||
className="me-1 align-middle"
|
||||
/>
|
||||
{t('login.agreementPrefix')}
|
||||
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('privacy')}>
|
||||
{t('login.privacy')}
|
||||
</button>
|
||||
{t('login.and')}
|
||||
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('terms')}>
|
||||
{t('login.terms')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 协议弹窗 */}
|
||||
<Dialog open={!!agreementOpen} onOpenChange={() => setAgreementOpen(null)} title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}>
|
||||
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
|
||||
<Dialog
|
||||
open={Boolean(agreementOpen)}
|
||||
onOpenChange={() => setAgreementOpen(null)}
|
||||
title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-7 text-slate-600">
|
||||
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
|
||||
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
|
||||
<p>2. 我们按照已公布的安全措施处理会话数据;涉及第三方服务时,处理范围以隐私政策及相关服务条款为准。</p>
|
||||
<p>3. 数据导出、删除和账号注销申请按照正式发布的隐私政策处理。</p>
|
||||
<p>4. 使用翻译和回复建议时,相关内容可能发送至已配置的模型服务商处理,具体范围以隐私政策为准。</p>
|
||||
<p>我们仅处理提供跨境沟通、客户管理和团队协作所必需的数据。</p>
|
||||
<p>使用翻译与回复建议时,相关内容会发送至企业已配置的模型服务商处理。</p>
|
||||
<p>数据导出、删除和账号注销按照正式发布的政策与协议执行。</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { register as registerApi } from '@/services/api/auth'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
@@ -11,24 +10,25 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input, Field } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { AuthShell } from '@/components/auth/AuthShell'
|
||||
import { INDUSTRIES } from '@/config/constants'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2, '请输入您的姓名'),
|
||||
account: z.string().min(5, '请输入手机号或邮箱'),
|
||||
password: z.string().min(8, '密码至少8位').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, '请选择团队规模'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const TEAM_SIZES = ['仅我一人', '2—5人', '6—20人', '21—50人', '50人以上']
|
||||
const fieldClass = 'h-10 rounded-xl border-slate-200 bg-white focus-visible:ring-blue-100'
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const setSession = useAuthStore((s) => s.setSession)
|
||||
const setSession = useAuthStore((state) => state.setSession)
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -43,77 +43,74 @@ export function RegisterPage() {
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (!agreed) {
|
||||
toast.warning('请先同意《隐私政策》和《服务协议》')
|
||||
toast.warning('请先同意隐私政策和服务协议')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const session = await registerApi(values)
|
||||
setSession(session)
|
||||
toast.success('企业创建成功', '接下来请完成企业初始化配置')
|
||||
navigate('/channels?setup=choose')
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e))
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-[440px]">
|
||||
<div className="mb-8 flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-white">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-base font-semibold">灵犀跨境</p>
|
||||
<p className="text-xs text-muted-foreground">注册并创建您的企业</p>
|
||||
</div>
|
||||
</div>
|
||||
<AuthShell
|
||||
heading="从第一条消息开始,建立清晰的客户关系。"
|
||||
description="创建企业工作台后,可按需连接 Telegram、WhatsApp 或企业邮箱,并邀请团队成员协作。"
|
||||
topAction={(
|
||||
<p className="text-xs text-slate-500">
|
||||
已有账号? <Link to="/login" className="font-medium text-blue-600 hover:text-blue-700">登录</Link>
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<div className="mb-7">
|
||||
<p className="text-xs font-semibold tracking-[0.15em] text-blue-600">CREATE WORKSPACE</p>
|
||||
<h2 className="mt-3 text-[28px] font-semibold tracking-[-0.035em] text-slate-950">创建企业工作台</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">填写基础信息,稍后再连接消息渠道。</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="姓名" required error={errors.name?.message}>
|
||||
<Input placeholder="您的姓名" {...register('name')} />
|
||||
<Input className={fieldClass} placeholder="您的姓名" autoComplete="name" {...register('name')} />
|
||||
</Field>
|
||||
<Field label="手机号或邮箱" required error={errors.account?.message}>
|
||||
<Input placeholder="用于登录与接收通知" {...register('account')} />
|
||||
<Input className={fieldClass} placeholder="用于登录" autoComplete="username" {...register('account')} />
|
||||
</Field>
|
||||
<Field label="设置密码" required error={errors.password?.message} hint="8—32位,建议包含字母与数字">
|
||||
<Input type="password" placeholder="请设置登录密码" {...register('password')} />
|
||||
</div>
|
||||
<Field label="设置密码" required error={errors.password?.message} hint="8—32 位">
|
||||
<Input className={fieldClass} type="password" placeholder="请设置登录密码" autoComplete="new-password" {...register('password')} />
|
||||
</Field>
|
||||
<Field label="企业名称" required error={errors.company?.message}>
|
||||
<Input className={fieldClass} placeholder="请输入企业名称" {...register('company')} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="所属行业" required error={errors.industry?.message}>
|
||||
<Select placeholder="请选择" options={INDUSTRIES.map((item) => ({ value: item, label: item }))} {...register('industry')} />
|
||||
</Field>
|
||||
<Field label="企业名称" required error={errors.company?.message}>
|
||||
<Input placeholder="如:徐州欧汇科技有限公司" {...register('company')} />
|
||||
<Field label="团队规模" required error={errors.teamSize?.message}>
|
||||
<Select placeholder="请选择" options={TEAM_SIZES.map((item) => ({ value: item, label: item }))} {...register('teamSize')} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="所属行业" required error={errors.industry?.message}>
|
||||
<Select placeholder="请选择" options={INDUSTRIES.map((i) => ({ value: i, label: i }))} {...register('industry')} />
|
||||
</Field>
|
||||
<Field label="团队规模" required error={errors.teamSize?.message}>
|
||||
<Select placeholder="请选择" options={TEAM_SIZES.map((s) => ({ value: s, label: s }))} {...register('teamSize')} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onCheckedChange={setAgreed}
|
||||
label="我已阅读并同意《隐私政策》和《服务协议》"
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onCheckedChange={setAgreed}
|
||||
label="我已阅读并同意《隐私政策》和《服务协议》"
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" loading={loading}>
|
||||
创建企业并开始使用
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
已有账号?{' '}
|
||||
<Link to="/login" className="font-medium text-primary hover:underline">
|
||||
直接登录
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="h-11 w-full rounded-xl shadow-[0_10px_24px_-14px_rgba(37,99,235,.8)]" loading={loading}>
|
||||
创建企业
|
||||
</Button>
|
||||
<p className="text-center text-[11px] leading-5 text-slate-400">
|
||||
注册后先选择需要接入的渠道,也可以暂时跳过。
|
||||
</p>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -233,19 +233,18 @@ export function ChannelsPage() {
|
||||
.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="page-shell">
|
||||
<section className="relative mb-6 overflow-hidden rounded-[16px] border border-slate-200 bg-white px-5 py-6 shadow-[0_8px_28px_-24px_rgba(15,23,42,.55)] md:px-6">
|
||||
<div className="pointer-events-none absolute -right-20 -top-24 h-64 w-64 rounded-full bg-primary-100/55 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">
|
||||
<div className="mb-3 inline-flex items-center gap-2 rounded-[8px] border border-primary-100 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700">
|
||||
<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>
|
||||
渠道连接
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-[-0.035em] text-slate-950 md:text-3xl">渠道与账号</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">渠道与账号</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||
在一个工作台管理 WhatsApp、Telegram 与企业邮箱消息。
|
||||
</p>
|
||||
@@ -256,7 +255,7 @@ export function ChannelsPage() {
|
||||
{ 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">
|
||||
<div key={stat.label} className="rounded-[12px] border border-slate-200 bg-slate-50/70 p-3">
|
||||
<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>
|
||||
@@ -312,7 +311,7 @@ export function ChannelsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-[14px] border border-slate-200 bg-white p-3 shadow-sm">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">连接更多工作账号</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">每种渠道可绑定多个账号,并指定一个默认账号用于发起新会话。</p>
|
||||
@@ -335,17 +334,17 @@ export function ChannelsPage() {
|
||||
const meta = CHANNEL_META[ch.type]
|
||||
const st = STATUS_MAP[ch.status]
|
||||
return (
|
||||
<Card data-testid={`channel-card-${ch.type}`} key={ch.id} className="group overflow-hidden border-slate-200/80 bg-white transition-all duration-300 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-[0_18px_50px_rgba(15,23,42,.08)]">
|
||||
<Card data-testid={`channel-card-${ch.type}`} key={ch.id} className="group overflow-hidden bg-white transition-all duration-200 hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_12px_28px_-18px_rgba(37,99,235,.5)]">
|
||||
<div className={
|
||||
ch.type === 'telegram'
|
||||
? 'h-1 bg-gradient-to-r from-sky-400 to-cyan-300'
|
||||
? 'h-[3px] bg-primary-500'
|
||||
: ch.type === 'whatsapp'
|
||||
? 'h-1 bg-gradient-to-r from-blue-500 via-cyan-400 to-violet-400'
|
||||
: 'h-1 bg-gradient-to-r from-slate-500 to-slate-300'
|
||||
? 'h-[3px] bg-sky-500'
|
||||
: 'h-[3px] bg-slate-500'
|
||||
} />
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-slate-100 transition-transform duration-300 group-hover:scale-105">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-[12px] bg-slate-50 ring-1 ring-slate-100 transition-transform duration-200 group-hover:scale-[1.03]">
|
||||
<ChannelIcon channel={ch.type} size="lg" />
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-end gap-1.5">
|
||||
|
||||
@@ -175,7 +175,7 @@ export function CustomersPage() {
|
||||
if (isError) return <ErrorState onRetry={() => refetch()} />
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="page-shell">
|
||||
<PageHeader
|
||||
title="客户管理"
|
||||
description={`共 ${total} 个客户`}
|
||||
@@ -204,27 +204,27 @@ export function CustomersPage() {
|
||||
</>
|
||||
}
|
||||
extra={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-[14px] border border-slate-200 bg-white p-3 shadow-sm">
|
||||
<div className="relative min-w-[200px] max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
placeholder="搜索客户名称/公司/国家/产品"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
className="h-9 w-full rounded-md border border-input bg-card pl-8 pr-3 text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
className="h-9 w-full rounded-[9px] border border-slate-200 bg-slate-50/60 pl-8 pr-3 text-sm placeholder:text-slate-400 focus:border-primary focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary-100"
|
||||
/>
|
||||
</div>
|
||||
<Select placeholder="国家" value={country} onChange={(e) => setCountry(e.target.value)} options={COUNTRIES.map((c) => ({ value: c.code, label: `${c.flag} ${c.zh}` }))} className="w-32" />
|
||||
<Select placeholder="渠道" value={channel} onChange={(e) => setChannel(e.target.value)} options={Object.entries(CHANNEL_META).map(([k, v]) => ({ value: k, label: v.name }))} className="w-28" />
|
||||
<Select placeholder="阶段" value={stage} onChange={(e) => setStage(e.target.value)} options={CUSTOMER_STAGES.map((s) => ({ value: s.value, label: s.label }))} className="w-28" />
|
||||
<Select placeholder="意向" value={intentLevel} onChange={(e) => setIntentLevel(e.target.value)} options={INTENT_LEVELS.map((l) => ({ value: l.value, label: l.label }))} className="w-28" />
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-card p-0.5 ml-auto">
|
||||
<div className="ml-auto flex items-center gap-1 rounded-[9px] border border-slate-200 bg-slate-50 p-0.5">
|
||||
{[
|
||||
{ v: 'table' as const, icon: TableProperties },
|
||||
{ v: 'card' as const, icon: LayoutGrid },
|
||||
{ v: 'kanban' as const, icon: List },
|
||||
].map((opt) => (
|
||||
<button key={opt.v} onClick={() => setView(opt.v)} className={cn('rounded p-1.5', view === opt.v ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground')}>
|
||||
<button key={opt.v} onClick={() => setView(opt.v)} className={cn('rounded-[7px] p-1.5 transition', view === opt.v ? 'bg-white text-primary-700 shadow-sm' : 'text-slate-400 hover:text-slate-800')}>
|
||||
<opt.icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
@@ -235,7 +235,7 @@ export function CustomersPage() {
|
||||
|
||||
{/* 批量操作 */}
|
||||
{selected.size > 0 && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2">
|
||||
<div className="mb-3 flex items-center gap-2 rounded-[12px] border border-primary-200 bg-primary-50 px-3 py-2 shadow-sm">
|
||||
<span className="text-sm font-medium text-primary-700">已选 {selected.size} 位客户</span>
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" onClick={() => { setBatchValue(''); setBatchDialog('assign') }}><UserRoundPlus className="h-4 w-4" />批量分配</Button>
|
||||
@@ -246,7 +246,7 @@ export function CustomersPage() {
|
||||
|
||||
{/* 表格视图 */}
|
||||
{view === 'table' && (
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<div className="overflow-hidden rounded-[14px] border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,.03)]">
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
|
||||
@@ -92,17 +92,17 @@ export function DashboardPage() {
|
||||
const s = data.stats
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[1600px] p-4 md:p-7">
|
||||
<div className="page-shell">
|
||||
{/* 顶部欢迎区 */}
|
||||
<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="mb-6 overflow-hidden rounded-[16px] border border-slate-200 bg-white px-5 py-5 shadow-[0_8px_28px_-24px_rgba(15,23,42,.55)] md:px-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]">
|
||||
<p className="eyebrow">今日工作概览</p>
|
||||
<h1 className="mt-1.5 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)}
|
||||
{dateStr} · 数据更新于 {formatDateTime(data.updatedAt).slice(11)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -114,9 +114,9 @@ export function DashboardPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex gap-2 overflow-x-auto border-t border-slate-100 pt-4 scrollbar-thin">
|
||||
<div className="mt-5 flex flex-wrap gap-2 border-t border-slate-100 pt-4">
|
||||
{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}>
|
||||
<Button key={a.label} variant="ghost" size="sm" className="rounded-[9px] 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>
|
||||
@@ -125,10 +125,10 @@ export function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* 核心指标 */}
|
||||
<div className="mb-3 flex items-end justify-between">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">工作台</p>
|
||||
<h2 className="mt-1 text-lg font-semibold tracking-tight">今日重点</h2>
|
||||
<h2 className="section-title">今日重点</h2>
|
||||
<p className="section-description">优先处理待回复、跟进与高意向客户</p>
|
||||
</div>
|
||||
<button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button>
|
||||
</div>
|
||||
@@ -138,15 +138,41 @@ export function DashboardPage() {
|
||||
<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} />
|
||||
<StatCard title="AI回复采纳率" value={`${s.aiAdoptionRate.value}%`} delta={s.aiAdoptionRate.delta} icon={Zap} />
|
||||
</div>
|
||||
<Card className="mt-3 overflow-hidden">
|
||||
<div className="grid grid-cols-2 divide-x divide-y divide-slate-100 md:grid-cols-4 md:divide-y-0">
|
||||
{[
|
||||
{ label: '风险预警', value: s.openRisks.value, icon: AlertTriangle, onClick: () => navigate('/risks') },
|
||||
{ label: '平均首次响应', value: `${s.avgFirstResponseMin.value} 分钟`, icon: Timer },
|
||||
{ label: '本月客户转化率', value: `${s.monthConversionRate.value}%`, icon: BadgePercent },
|
||||
{ label: '智能回复采纳率', value: `${s.aiAdoptionRate.value}%`, icon: Zap },
|
||||
].map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.label}
|
||||
onClick={item.onClick}
|
||||
className="flex min-h-[86px] items-center gap-3 px-4 py-3 text-left transition hover:bg-slate-50 disabled:cursor-default"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] bg-slate-100 text-slate-600">
|
||||
<item.icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span>
|
||||
<span className="block text-[11px] font-medium text-slate-500">{item.label}</span>
|
||||
<span className="mt-0.5 block text-lg font-semibold tracking-tight text-slate-950">{item.value}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 中部三栏 */}
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 className="section-title">待处理工作</h2>
|
||||
<p className="section-description">集中查看任务、重点客户与各渠道消息</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||
{/* 今日待办 */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
@@ -257,9 +283,17 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 图表区 */}
|
||||
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 className="section-title">经营趋势</h2>
|
||||
<p className="section-description">客户来源、转化阶段与团队响应表现</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="询盘趋势(近14天)" height={240}>
|
||||
<TrendChart data={data.inquiryTrend} series={[{ key: 'inquiries', name: '询盘数' }, { key: 'customers', name: '新增客户' }]} />
|
||||
</ChartCard>
|
||||
@@ -277,6 +311,7 @@ export function DashboardPage() {
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 最近风险 */}
|
||||
<Card className="mt-4">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
BellOff,
|
||||
Brain,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
Eraser,
|
||||
@@ -48,6 +49,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { Dropdown } from '@/components/ui/dropdown'
|
||||
import { Segmented } from '@/components/ui/tabs'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
||||
import { CountryFlag } from '@/components/common/CountryFlag'
|
||||
@@ -127,6 +129,7 @@ export function InboxPage() {
|
||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'chat' | 'insight'>('list')
|
||||
const [showInsight, setShowInsight] = useState(false) // desktop right panel toggle
|
||||
const [showAiReplies, setShowAiReplies] = useState(false)
|
||||
const [intentSaving, setIntentSaving] = 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'
|
||||
@@ -294,6 +297,7 @@ export function InboxPage() {
|
||||
message?: Message
|
||||
customer?: Customer
|
||||
conversation?: Conversation
|
||||
backfill?: import('@/types').AiBackfillStatus
|
||||
}
|
||||
try { event = JSON.parse(String(raw.data)) } catch { return }
|
||||
const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>(
|
||||
@@ -342,6 +346,15 @@ export function InboxPage() {
|
||||
},
|
||||
)
|
||||
}
|
||||
if (
|
||||
event.type === 'conversation.ai-backfill.updated'
|
||||
&& event.conversationId
|
||||
&& event.backfill
|
||||
) {
|
||||
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => current
|
||||
? { ...current, aiBackfill: event.backfill }
|
||||
: current)
|
||||
}
|
||||
if (event.conversationId && event.conversation) {
|
||||
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => {
|
||||
const customer = event.customer || current?.customer
|
||||
@@ -350,6 +363,7 @@ export function InboxPage() {
|
||||
// insight components require both the conversation and its customer.
|
||||
if (!customer) return current
|
||||
return {
|
||||
...current,
|
||||
conversation: event.conversation,
|
||||
customer,
|
||||
}
|
||||
@@ -424,16 +438,19 @@ export function InboxPage() {
|
||||
text,
|
||||
convId: id,
|
||||
autoTranslate,
|
||||
targetLang,
|
||||
translationCn,
|
||||
}: {
|
||||
text: string
|
||||
convId: string
|
||||
autoTranslate?: boolean
|
||||
targetLang?: string
|
||||
translationCn?: string
|
||||
}) => {
|
||||
return sendMessage(id, {
|
||||
text,
|
||||
autoTranslate,
|
||||
targetLang,
|
||||
translationCn,
|
||||
replyToId: replyingTo?.id,
|
||||
replyPreview: replyingTo?.text,
|
||||
@@ -447,12 +464,13 @@ export function InboxPage() {
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
})
|
||||
|
||||
const handleSend = async (text: string, autoTranslate = true) => {
|
||||
const handleSend = async (text: string, autoTranslate = true, targetLang = 'auto') => {
|
||||
if (!convId) return
|
||||
await sendMsg.mutateAsync({
|
||||
text,
|
||||
convId,
|
||||
autoTranslate,
|
||||
targetLang,
|
||||
translationCn: draftTranslationCn || undefined,
|
||||
})
|
||||
if (convDetail?.conversation.channel === 'whatsapp') {
|
||||
@@ -461,6 +479,22 @@ export function InboxPage() {
|
||||
setDraftTranslationCn('')
|
||||
}
|
||||
|
||||
const handleTargetLanguageChange = async (targetLang: string) => {
|
||||
if (!convId) return
|
||||
queryClient.setQueryData(['conversation', convId], (current: any) => current
|
||||
? {
|
||||
...current,
|
||||
conversation: { ...current.conversation, translationTargetLang: targetLang },
|
||||
}
|
||||
: current)
|
||||
try {
|
||||
await updateConversation(convId, { translationTargetLang: targetLang })
|
||||
} catch (error) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['conversation', convId] })
|
||||
toast.error(errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDraftChange = useCallback((text: string) => {
|
||||
setDraftText(text)
|
||||
if (!convId || convDetail?.conversation.channel !== 'whatsapp') return
|
||||
@@ -899,12 +933,12 @@ export function InboxPage() {
|
||||
</>
|
||||
)
|
||||
|
||||
const handleAdopt = (content: string, translationCn: string) => {
|
||||
const handleAdopt = (content: string, translationCn: string, suggestionId: string) => {
|
||||
setDraftText(content)
|
||||
setDraftTranslationCn(translationCn)
|
||||
setShowAiReplies(false)
|
||||
if (convId) {
|
||||
void import('@/services/api/ai').then(({ recordAiAdoption }) => recordAiAdoption(convId, content))
|
||||
void import('@/services/api/ai').then(({ recordAiAdoption }) => recordAiAdoption(convId, content, suggestionId))
|
||||
}
|
||||
toast.success('回复已采用,请在输入区编辑后发送')
|
||||
}
|
||||
@@ -921,6 +955,25 @@ export function InboxPage() {
|
||||
window.localStorage.setItem('lingxi-message-view-mode', nextMode)
|
||||
}
|
||||
|
||||
const handleIntentToggle = async (enabled: boolean) => {
|
||||
if (!convId || intentSaving) return
|
||||
setIntentSaving(true)
|
||||
try {
|
||||
const conversation = await updateConversation(convId, { messageIntentEnabled: enabled })
|
||||
queryClient.setQueryData(['conversation', convId], (current: any) => current
|
||||
? { ...current, conversation: { ...current.conversation, ...conversation } }
|
||||
: current)
|
||||
if (enabled) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['messages', convId] })
|
||||
}
|
||||
toast.success(enabled ? '已开启逐句意图识别' : '已关闭逐句意图识别')
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setIntentSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const listItems = convData?.list ?? []
|
||||
const renderMessageTimeline = () => {
|
||||
let previousDay = ''
|
||||
@@ -958,6 +1011,7 @@ export function InboxPage() {
|
||||
customerName={convDetail?.customer?.name}
|
||||
customerAvatar={convDetail?.customer?.avatarUrl}
|
||||
channel={convDetail?.conversation.channel}
|
||||
showIntent={convDetail?.conversation.messageIntentEnabled !== false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -1006,6 +1060,9 @@ export function InboxPage() {
|
||||
loading={convDetailLoading}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
intentEnabled={convDetail?.conversation.messageIntentEnabled !== false}
|
||||
intentSaving={intentSaving}
|
||||
onIntentToggle={handleIntentToggle}
|
||||
wallpaper={chatWallpaper}
|
||||
onWallpaperChange={setChatWallpaper}
|
||||
onTelegramAction={(action) => {
|
||||
@@ -1027,6 +1084,7 @@ export function InboxPage() {
|
||||
customer={convDetail.customer}
|
||||
conversation={convDetail.conversation}
|
||||
onOpenProfile={() => setMobilePanel('insight')}
|
||||
aiBackfill={convDetail.aiBackfill}
|
||||
/>
|
||||
)}
|
||||
<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`}>
|
||||
@@ -1044,6 +1102,7 @@ export function InboxPage() {
|
||||
{showAiReplies && convId ? (
|
||||
<AiReplies
|
||||
conversationId={convId}
|
||||
targetLanguage={convDetail?.conversation.translationTargetLang || 'auto'}
|
||||
onAdopt={handleAdopt}
|
||||
onEdit={handleEdit}
|
||||
onClose={() => setShowAiReplies(false)}
|
||||
@@ -1067,6 +1126,9 @@ export function InboxPage() {
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
channel={convDetail?.conversation.channel}
|
||||
targetLanguage={convDetail?.conversation.translationTargetLang || 'auto'}
|
||||
detectedLanguage={convDetail?.customer.language}
|
||||
onTargetLanguageChange={handleTargetLanguageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1087,7 +1149,7 @@ export function InboxPage() {
|
||||
|
||||
// 桌面端三栏布局
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-white">
|
||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-slate-100/70">
|
||||
{noteDialog}
|
||||
{whatsappGroupDialog}
|
||||
{telegramDialogs}
|
||||
@@ -1104,7 +1166,7 @@ export function InboxPage() {
|
||||
{/* 左栏:会话列表 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]"
|
||||
className="relative z-20 w-80 shrink-0 border-e border-slate-300/90 bg-white shadow-[8px_0_24px_-24px_rgba(15,23,42,.75)] lg:w-[360px]"
|
||||
>
|
||||
<ConversationList
|
||||
items={listItems}
|
||||
@@ -1126,7 +1188,7 @@ export function InboxPage() {
|
||||
{/* 中栏:聊天区 flex-1 */}
|
||||
<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'}`}
|
||||
className={`relative z-10 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[#eaf2f9] shadow-[inset_1px_0_0_rgba(255,255,255,.75)] ${showInsight ? '' : 'border-e-0'}`}
|
||||
>
|
||||
{convId ? (
|
||||
<>
|
||||
@@ -1136,6 +1198,9 @@ export function InboxPage() {
|
||||
loading={convDetailLoading}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
intentEnabled={convDetail?.conversation.messageIntentEnabled !== false}
|
||||
intentSaving={intentSaving}
|
||||
onIntentToggle={handleIntentToggle}
|
||||
wallpaper={chatWallpaper}
|
||||
onWallpaperChange={setChatWallpaper}
|
||||
onTelegramAction={(action) => {
|
||||
@@ -1156,6 +1221,7 @@ export function InboxPage() {
|
||||
customer={convDetail.customer}
|
||||
conversation={convDetail.conversation}
|
||||
onOpenProfile={() => setShowInsight(true)}
|
||||
aiBackfill={convDetail.aiBackfill}
|
||||
/>
|
||||
)}
|
||||
<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`}>
|
||||
@@ -1177,6 +1243,7 @@ export function InboxPage() {
|
||||
{showAiReplies ? (
|
||||
<AiReplies
|
||||
conversationId={convId}
|
||||
targetLanguage={convDetail?.conversation.translationTargetLang || 'auto'}
|
||||
onAdopt={handleAdopt}
|
||||
onEdit={handleEdit}
|
||||
onClose={() => setShowAiReplies(false)}
|
||||
@@ -1200,6 +1267,9 @@ export function InboxPage() {
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={() => setReplyingTo(null)}
|
||||
channel={convDetail?.conversation.channel}
|
||||
targetLanguage={convDetail?.conversation.translationTargetLang || 'auto'}
|
||||
detectedLanguage={convDetail?.customer.language}
|
||||
onTargetLanguageChange={handleTargetLanguageChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -1395,6 +1465,9 @@ function ChatHeader({
|
||||
loading,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
intentEnabled,
|
||||
intentSaving,
|
||||
onIntentToggle,
|
||||
wallpaper,
|
||||
onWallpaperChange,
|
||||
onTelegramAction,
|
||||
@@ -1408,6 +1481,9 @@ function ChatHeader({
|
||||
loading: boolean
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (v: ViewMode) => void
|
||||
intentEnabled: boolean
|
||||
intentSaving: boolean
|
||||
onIntentToggle: (enabled: boolean) => void
|
||||
wallpaper: ChatWallpaper
|
||||
onWallpaperChange: (wallpaper: ChatWallpaper) => void
|
||||
onTelegramAction: (action: ConversationAction) => void
|
||||
@@ -1494,6 +1570,26 @@ function ChatHeader({
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
<div
|
||||
data-testid="conversation-intent-toggle"
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-1 transition-colors',
|
||||
intentEnabled
|
||||
? 'border-violet-200 bg-violet-50 text-violet-700'
|
||||
: 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
)}
|
||||
title={intentEnabled ? '逐句意图识别已开启' : '逐句意图识别已关闭'}
|
||||
>
|
||||
<Brain className="h-3.5 w-3.5" />
|
||||
<span className="hidden text-[11px] font-medium lg:inline">意图识别</span>
|
||||
<Switch
|
||||
ariaLabel="当前会话逐句意图识别"
|
||||
checked={intentEnabled}
|
||||
disabled={intentSaving}
|
||||
onCheckedChange={onIntentToggle}
|
||||
className="scale-90"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger={(
|
||||
<Button variant="ghost" size="icon-sm" title="切换聊天背景" aria-label="切换聊天背景">
|
||||
|
||||
@@ -29,20 +29,28 @@ export function KnowledgePage() {
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editItem, setEditItem] = useState<KnowledgeItem | null>(null)
|
||||
const [searchQ, setSearchQ] = useState('')
|
||||
const [createDraft, setCreateDraft] = useState({ title: '', content: '' })
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['knowledge', category, keyword],
|
||||
queryFn: () => getKnowledge({ category, keyword }),
|
||||
})
|
||||
const { data: stats } = useQuery({ queryKey: ['knowledge-stats'], queryFn: getKnowledgeStats, enabled: tab === 'stats' })
|
||||
const { data: stats } = useQuery({ queryKey: ['knowledge-stats'], queryFn: getKnowledgeStats })
|
||||
const [searchResult, setSearchResult] = useState<any>(null)
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [uploadFiles, setUploadFiles] = useState<File[]>([])
|
||||
const [uploadDialog, setUploadDialog] = useState(false)
|
||||
const [uploadLoading, setUploadLoading] = useState(false)
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (p: any) => createKnowledge(p),
|
||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledge'] }); toast.success('知识已创建'); setCreateOpen(false) },
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
|
||||
toast.success('知识已创建并完成索引')
|
||||
setCreateDraft({ title: '', content: '' })
|
||||
setCreateOpen(false)
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
})
|
||||
|
||||
@@ -65,7 +73,7 @@ export function KnowledgePage() {
|
||||
<div className="p-4 md:p-6">
|
||||
<PageHeader
|
||||
title="企业知识库"
|
||||
description={`${list.length} 条知识 · 用于回复建议与翻译参考`}
|
||||
description="把产品、价格、物流和售后资料变成可检索、可引用的企业答案"
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
|
||||
@@ -74,6 +82,20 @@ export function KnowledgePage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{[
|
||||
['当前条目', data?.total || 0],
|
||||
['已分段索引', list.reduce((sum, item) => sum + Number(item.chunkCount || 0), 0)],
|
||||
['真实引用', stats?.totalCitations || 0],
|
||||
['待优化内容', (stats?.lowQuality?.length || 0) + (stats?.outdated?.length || 0)],
|
||||
].map(([label, value]) => (
|
||||
<Card key={String(label)} className="px-4 py-3">
|
||||
<p className="text-[11px] font-medium text-slate-400">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight text-slate-900">{value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onChange={setTab} className="mb-4" items={[
|
||||
{ value: 'list', label: '知识列表' },
|
||||
{ value: 'search', label: '检索测试' },
|
||||
@@ -109,10 +131,14 @@ export function KnowledgePage() {
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>引用 {item.citedCount} 次</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelative(item.updatedAt)}</span>
|
||||
<span>{item.chunkCount || 0} 个检索片段</span>
|
||||
<span>·</span>
|
||||
<span>{item.status === 'parsing' ? '解析中…' : item.status === 'failed' ? '解析失败' : ''}</span>
|
||||
<span>{Number(item.characterCount || item.content?.length || 0).toLocaleString('zh-CN')} 字符</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelative(item.updatedAt)}</span>
|
||||
{item.status !== 'ready' && <><span>·</span><span>{item.status === 'parsing' ? '解析中…' : '解析失败'}</span></>}
|
||||
</div>
|
||||
{item.parseMessage && <p className="mt-1 text-[11px] text-emerald-600">{item.parseMessage}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Switch checked={item.enabled} onCheckedChange={(v) => toggleMut.mutate({ id: item.id, enabled: v })} />
|
||||
@@ -148,8 +174,9 @@ export function KnowledgePage() {
|
||||
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p>
|
||||
{searchResult.hits.map((h: any) => (
|
||||
<div key={h.item.id} className="rounded-md border border-border p-3">
|
||||
<div className="flex items-center justify-between"><span className="text-sm font-medium">{h.item.title}</span><Badge variant="primary">{(h.score * 100).toFixed(0)}%</Badge></div>
|
||||
<div className="flex items-center justify-between"><span className="text-sm font-medium">{h.item.title}</span><Badge variant="primary">相关度 {(h.score * 100).toFixed(0)}%</Badge></div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{h.snippet}</p>
|
||||
<p className="mt-1 text-[10px] text-slate-400">命中片段 #{Number(h.chunkIndex || 0) + 1}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -175,34 +202,41 @@ export function KnowledgePage() {
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen} title="新建知识条目" footer={
|
||||
<Button onClick={() => {
|
||||
const title = (document.querySelector('#k-title') as HTMLInputElement)?.value
|
||||
const content = (document.querySelector('#k-content') as HTMLTextAreaElement)?.value
|
||||
if (!title) { toast.warning('请输入标题'); return }
|
||||
createMut.mutate({ title, content, category: category || 'faq' })
|
||||
}}>创建</Button>
|
||||
if (!createDraft.title.trim()) { toast.warning('请输入标题'); return }
|
||||
if (createDraft.content.trim().length < 20) { toast.warning('知识内容至少需要 20 个字符'); return }
|
||||
createMut.mutate({ ...createDraft, category: category || 'faq' })
|
||||
}} loading={createMut.isPending}>创建并索引</Button>
|
||||
}>
|
||||
<Field label="标题" required><Input id="k-title" placeholder="知识条目标题" /></Field>
|
||||
<Field label="内容" className="mt-3"><Textarea id="k-content" rows={5} placeholder="知识内容…" /></Field>
|
||||
<Field label="标题" required><Input value={createDraft.title} onChange={(event) => setCreateDraft((value) => ({ ...value, title: event.target.value }))} placeholder="例如:欧洲地区退换货政策" /></Field>
|
||||
<Field label="内容" className="mt-3"><Textarea value={createDraft.content} onChange={(event) => setCreateDraft((value) => ({ ...value, content: event.target.value }))} rows={8} placeholder="写明适用范围、条件、时效、例外和处理方式。系统会自动分段并用于回复建议。" /></Field>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={uploadDialog} onOpenChange={setUploadDialog} title="上传文件" footer={
|
||||
<Button disabled={!uploadFiles.length} onClick={async () => {
|
||||
const payload = await Promise.all(uploadFiles.map(async (file) => ({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
base64: await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
|
||||
reader.readAsDataURL(file)
|
||||
}),
|
||||
})))
|
||||
await uploadKnowledgeFiles(payload)
|
||||
toast.success(`${payload.length} 个知识文件解析完成`)
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
|
||||
setUploadFiles([])
|
||||
setUploadDialog(false)
|
||||
}}>上传并解析</Button>
|
||||
<Button disabled={!uploadFiles.length} loading={uploadLoading} onClick={async () => {
|
||||
setUploadLoading(true)
|
||||
try {
|
||||
const payload = await Promise.all(uploadFiles.map(async (file) => ({
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
base64: await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
|
||||
reader.readAsDataURL(file)
|
||||
}),
|
||||
})))
|
||||
const result = await uploadKnowledgeFiles(payload)
|
||||
toast.success(`${result.items.length} 个文件解析并建立检索索引`)
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
|
||||
setUploadFiles([])
|
||||
setUploadDialog(false)
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setUploadLoading(false)
|
||||
}
|
||||
}}>上传、解析并索引</Button>
|
||||
}>
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
@@ -16,6 +17,7 @@ import { CountryFlag } from '@/components/common/CountryFlag'
|
||||
import { IntentScoreBadge, OpportunityStageBadge, TaskPriorityBadge } from '@/components/common/StatusBadges'
|
||||
import { TaskFormDialog } from '@/components/crm/TaskFormDialog'
|
||||
import { getOpportunities, updateOpportunity, getTasks, updateTask } from '@/services/api/crm'
|
||||
import { getTeam } from '@/services/api/org'
|
||||
import { OPPORTUNITY_STAGES, taskTypeLabel, TASK_TYPES, TASK_PRIORITIES } from '@/config/constants'
|
||||
import { formatDateTime, formatMoney } from '@/utils/format'
|
||||
import { cn } from '@/utils/cn'
|
||||
@@ -24,7 +26,11 @@ import type { FollowTask, Opportunity } from '@/types'
|
||||
export function OpportunitiesPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const [view, setView] = useState('kanban') // 'kanban' | 'tasks' | 'calendar'
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const requestedView = searchParams.get('tab')
|
||||
const [view, setView] = useState(
|
||||
requestedView === 'tasks' || requestedView === 'calendar' ? requestedView : 'kanban',
|
||||
)
|
||||
const [taskFilter, setTaskFilter] = useState('all')
|
||||
const [taskDialog, setTaskDialog] = useState(false)
|
||||
|
||||
@@ -36,6 +42,8 @@ export function OpportunitiesPage() {
|
||||
queryKey: ['tasks', taskFilter],
|
||||
queryFn: () => getTasks({ filter: taskFilter, ownerId: user?.id }),
|
||||
})
|
||||
const { data: team = [] } = useQuery({ queryKey: ['team'], queryFn: getTeam })
|
||||
const memberNames = new Map(team.map((member) => [member.id, member.name]))
|
||||
|
||||
const oppMut = useMutation({
|
||||
mutationFn: ({ id, stage }: { id: string; stage: string }) => updateOpportunity(id, { stage: stage as any }),
|
||||
@@ -77,7 +85,7 @@ export function OpportunitiesPage() {
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1).getDay()
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="page-shell">
|
||||
<PageHeader
|
||||
title="商机与跟进"
|
||||
description="管理商机流转与团队跟进任务"
|
||||
@@ -90,8 +98,11 @@ export function OpportunitiesPage() {
|
||||
|
||||
<Tabs
|
||||
value={view}
|
||||
onChange={setView}
|
||||
className="mb-4"
|
||||
onChange={(next) => {
|
||||
setView(next)
|
||||
setSearchParams(next === 'kanban' ? {} : { tab: next }, { replace: true })
|
||||
}}
|
||||
className="mb-5"
|
||||
items={[
|
||||
{ value: 'kanban', label: '商机看板', count: opps.length },
|
||||
{ value: 'tasks', label: '跟进任务', count: tasks.filter((t) => t.status !== 'done').length },
|
||||
@@ -100,17 +111,17 @@ export function OpportunitiesPage() {
|
||||
/>
|
||||
|
||||
{view === 'kanban' && (
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin" style={{ minHeight: '60vh' }}>
|
||||
<div className="flex gap-4 overflow-x-auto pb-3 scrollbar-thin" style={{ minHeight: '60vh' }}>
|
||||
{groupedOpps.map((col) => (
|
||||
<div
|
||||
key={col.value}
|
||||
className="w-64 shrink-0 rounded-lg bg-muted/40"
|
||||
className="w-[272px] shrink-0 rounded-[14px] border border-slate-200 bg-slate-100/65"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => handleDrop(e, col.value)}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-lg bg-muted px-3 py-2">
|
||||
<span className="text-sm font-medium">{col.label}</span>
|
||||
<Badge variant="primary">{col.items.length}</Badge>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-[14px] border-b border-slate-200 bg-white/90 px-3.5 py-3 backdrop-blur">
|
||||
<span className="text-sm font-semibold text-slate-800">{col.label}</span>
|
||||
<Badge variant="outline">{col.items.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 p-2">
|
||||
{col.items.map((opp) => (
|
||||
@@ -118,7 +129,7 @@ export function OpportunitiesPage() {
|
||||
key={opp.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, opp)}
|
||||
className="cursor-grab p-3 active:cursor-grabbing hover:shadow-sm"
|
||||
className="cursor-grab p-3.5 active:cursor-grabbing hover:border-primary-200 hover:shadow-[0_8px_20px_-16px_rgba(37,99,235,.55)]"
|
||||
>
|
||||
<p className="text-sm font-medium truncate">{opp.name}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{opp.customerName} <CountryFlag code={opp.country} withName={false} /></p>
|
||||
@@ -138,13 +149,13 @@ export function OpportunitiesPage() {
|
||||
|
||||
{view === 'tasks' && (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap gap-1">
|
||||
<div className="mb-4 flex flex-wrap gap-1 rounded-[12px] border border-slate-200 bg-white p-1.5 shadow-sm">
|
||||
{[
|
||||
{ v: 'all', l: '全部' }, { v: 'mine', l: '我的待办' },
|
||||
{ v: 'today', l: '今日' }, { v: 'overdue', l: '已逾期' },
|
||||
{ v: 'done', l: '已完成' },
|
||||
].map((f) => (
|
||||
<button key={f.v} onClick={() => setTaskFilter(f.v)} className={cn('rounded-full px-2.5 py-1 text-xs transition-colors', taskFilter === f.v ? 'bg-primary font-medium text-white' : 'bg-muted text-muted-foreground hover:bg-border')}>{f.l}</button>
|
||||
<button key={f.v} onClick={() => setTaskFilter(f.v)} className={cn('rounded-[8px] px-3 py-1.5 text-xs transition-colors', taskFilter === f.v ? 'bg-slate-900 font-medium text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900')}>{f.l}</button>
|
||||
))}
|
||||
</div>
|
||||
<Card>
|
||||
@@ -160,7 +171,7 @@ export function OpportunitiesPage() {
|
||||
<Td><span className="text-xs">{t.customerName}</span></Td>
|
||||
<Td><TaskPriorityBadge priority={t.priority} /></Td>
|
||||
<Td><span className={cn('text-xs', t.status === 'overdue' && 'text-red-600 font-medium')}>{formatDateTime(t.dueAt)}</span></Td>
|
||||
<Td><span className="text-xs">{t.ownerId === 'u-003' ? '李婷' : t.ownerId === 'u-004' ? '赵磊' : t.ownerId === 'u-005' ? '周敏' : t.ownerId === 'u-008' ? '郑楠' : '其他'}</span></Td>
|
||||
<Td><span className="text-xs">{memberNames.get(t.ownerId) ?? '未分配'}</span></Td>
|
||||
<Td>
|
||||
{t.status !== 'done' && (
|
||||
<Button size="sm" variant="ghost" onClick={() => taskMut.mutate({ id: t.id, data: { status: 'done' } })}>完成</Button>
|
||||
|
||||
@@ -17,7 +17,7 @@ const SECTIONS = [
|
||||
{ key: 'company', label: '企业资料', icon: UserCog },
|
||||
{ key: 'lang', label: '语言与时区', icon: Globe },
|
||||
{ key: 'notifications', label: '消息通知', icon: Bell },
|
||||
{ key: 'ai', label: 'AI回复设置', icon: Languages },
|
||||
{ key: 'ai', label: '智能回复', icon: Languages },
|
||||
{ key: 'safety', label: '安全设置', icon: ShieldCheck },
|
||||
{ key: 'api', label: 'API Key', icon: KeyRound },
|
||||
]
|
||||
@@ -55,19 +55,33 @@ export function SettingsPage() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-56px)]">
|
||||
<div className="w-48 shrink-0 border-e border-border p-3 space-y-1 overflow-y-auto scrollbar-thin hidden md:block">
|
||||
<div className="page-shell">
|
||||
<PageHeader title="设置" description="管理企业资料、通知、智能能力与账号安全" />
|
||||
|
||||
<div className="mb-4 rounded-[12px] border border-slate-200 bg-white p-2 shadow-sm md:hidden">
|
||||
<Select
|
||||
aria-label="设置分类"
|
||||
value={section}
|
||||
onChange={(event) => setSection(event.target.value)}
|
||||
className="w-full"
|
||||
options={SECTIONS.map((item) => ({ value: item.key, label: item.label }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-5 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
<div className="sticky top-4 hidden space-y-1 rounded-[14px] border border-slate-200 bg-white p-2 shadow-sm md:block">
|
||||
<p className="px-3 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-400">设置分类</p>
|
||||
{SECTIONS.map((s) => (
|
||||
<button key={s.key} onClick={() => setSection(s.key)} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm ${section === s.key ? 'bg-primary-50 font-medium text-primary-700' : 'text-muted-foreground hover:text-foreground'}`}>
|
||||
<button key={s.key} onClick={() => setSection(s.key)} className={`flex w-full items-center gap-2 rounded-[9px] px-3 py-2.5 text-sm transition ${section === s.key ? 'bg-primary-50 font-semibold text-primary-700 shadow-[inset_0_0_0_1px_rgba(37,99,235,.08)]' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'}`}>
|
||||
<s.icon className="h-4 w-4" />{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
|
||||
<div className="min-w-0">
|
||||
{section === 'company' && settings && (
|
||||
<Card>
|
||||
<CardHeader title="企业资料" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4">
|
||||
<CardHeader title="企业资料" description="用于团队空间、通知邮件和对外业务资料" />
|
||||
<div className="grid grid-cols-1 gap-5 p-5 md:grid-cols-2">
|
||||
<Field label="企业名称"><Input value={companyDraft.name} onChange={(e) => setCompanyDraft((v) => ({ ...v, name: e.target.value }))} /></Field>
|
||||
<Field label="所属行业"><Input value={companyDraft.industry} onChange={(e) => setCompanyDraft((v) => ({ ...v, industry: e.target.value }))} /></Field>
|
||||
<Field label="联系方式"><Input value={companyDraft.contact} onChange={(e) => setCompanyDraft((v) => ({ ...v, contact: e.target.value }))} /></Field>
|
||||
@@ -79,20 +93,25 @@ export function SettingsPage() {
|
||||
)}
|
||||
{section === 'ai' && settings && (
|
||||
<Card>
|
||||
<CardHeader title="AI 回复设置" />
|
||||
<div className="space-y-4 p-4">
|
||||
<CardHeader title="智能回复" description="控制回复建议、分析和发送前确认规则" />
|
||||
<div className="divide-y divide-slate-100 px-5">
|
||||
{[
|
||||
{ key: 'autoGenerate', label: '自动生成回复建议' },
|
||||
{ key: 'requireConfirm', label: '发送前必须人工确认' },
|
||||
{ key: 'sentimentAnalysis', label: '启用情感分析' },
|
||||
{ key: 'messageIntentAnalysis', label: '逐条客户消息意图识别' },
|
||||
{ key: 'contextualTranslation', label: '翻译时参考会话上下文' },
|
||||
{ key: 'riskAlert', label: '启用风险预警' },
|
||||
{ key: 'cultureCheck', label: '启用文化敏感检测' },
|
||||
{ key: 'allowHistoryRef', label: '允许AI引用历史成功回复' },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between">
|
||||
<div key={item.key} className="flex items-center justify-between py-4">
|
||||
<span className="text-sm">{item.label}</span>
|
||||
<Switch
|
||||
checked={(settings.ai as any)[item.key]}
|
||||
ariaLabel={item.label}
|
||||
checked={['messageIntentAnalysis', 'contextualTranslation'].includes(item.key)
|
||||
? (settings.ai as any)[item.key] !== false
|
||||
: Boolean((settings.ai as any)[item.key])}
|
||||
onCheckedChange={(v) => updateMut.mutate({ ai: { [item.key]: v } })}
|
||||
/>
|
||||
</div>
|
||||
@@ -102,8 +121,8 @@ export function SettingsPage() {
|
||||
)}
|
||||
{section === 'notifications' && settings && (
|
||||
<Card>
|
||||
<CardHeader title="消息通知设置" />
|
||||
<div className="space-y-4 p-4">
|
||||
<CardHeader title="消息通知" description="选择需要发送到通知中心的业务事件" />
|
||||
<div className="divide-y divide-slate-100 px-5">
|
||||
{[
|
||||
{ key: 'newInquiry', label: '新询盘提醒' },
|
||||
{ key: 'highIntent', label: '高意向客户提醒' },
|
||||
@@ -113,7 +132,7 @@ export function SettingsPage() {
|
||||
{ key: 'channelOffline', label: '渠道断开提醒' },
|
||||
{ key: 'planExpire', label: '套餐到期提醒' },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between">
|
||||
<div key={item.key} className="flex items-center justify-between py-4">
|
||||
<span className="text-sm">{item.label}</span>
|
||||
<Switch
|
||||
checked={(settings.notification as any)[item.key]}
|
||||
@@ -126,12 +145,12 @@ export function SettingsPage() {
|
||||
)}
|
||||
{section === 'safety' && (
|
||||
<Card>
|
||||
<CardHeader title="安全设置" />
|
||||
<div className="space-y-4 p-4">
|
||||
<CardHeader title="安全设置" description="修改密码后会撤销其他设备上的登录会话" />
|
||||
<div className="space-y-5 p-5">
|
||||
<Field label="当前密码"><Input type="password" value={passwords.currentPassword} onChange={(e) => setPasswords((v) => ({ ...v, currentPassword: e.target.value }))} /></Field>
|
||||
<Field label="新密码"><Input type="password" placeholder="至少10位,包含字母和数字" value={passwords.newPassword} onChange={(e) => setPasswords((v) => ({ ...v, newPassword: e.target.value }))} /></Field>
|
||||
<Field label="确认新密码"><Input type="password" value={passwords.confirmPassword} onChange={(e) => setPasswords((v) => ({ ...v, confirmPassword: e.target.value }))} /></Field>
|
||||
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />修改密码后,其他已登录设备需要重新登录。</div>
|
||||
<div className="rounded-[10px] border border-primary-100 bg-primary-50 p-3 text-sm text-primary-700"><ShieldCheck className="mr-1 inline h-4 w-4" />修改密码后,其他已登录设备需要重新登录。</div>
|
||||
<Button onClick={async () => {
|
||||
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
|
||||
try {
|
||||
@@ -146,8 +165,8 @@ export function SettingsPage() {
|
||||
)}
|
||||
{section === 'api' && (
|
||||
<Card>
|
||||
<CardHeader title="API 与 Webhook" />
|
||||
<div className="space-y-3 p-4">
|
||||
<CardHeader title="API Key" description="为自有系统或服务端集成创建访问凭据" />
|
||||
<div className="space-y-4 p-5">
|
||||
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,请立即复制并妥善保存。</p>
|
||||
<div className="flex gap-2">
|
||||
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
|
||||
@@ -168,7 +187,7 @@ export function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="divide-y divide-border rounded-lg border border-border">
|
||||
<div className="divide-y divide-slate-100 overflow-hidden rounded-[10px] border border-slate-200">
|
||||
{(apiKeys || []).map((key) => (
|
||||
<div key={key.id} className="flex items-center gap-3 px-3 py-2">
|
||||
<div className="min-w-0 flex-1"><p className="text-sm font-medium">{key.name}</p><p className="text-xs text-muted-foreground">{key.prefix}•••••• · {key.revoked ? '已撤销' : '有效'}</p></div>
|
||||
@@ -181,8 +200,8 @@ export function SettingsPage() {
|
||||
)}
|
||||
{section === 'lang' && (
|
||||
<Card>
|
||||
<CardHeader title="语言与时区" />
|
||||
<div className="space-y-4 p-4">
|
||||
<CardHeader title="语言与时区" description="调整界面显示语言与业务时间基准" />
|
||||
<div className="space-y-5 p-5">
|
||||
<Field label="界面语言">
|
||||
<Select value={localeDraft} options={[{value:'zh',label:'简体中文'},{value:'en',label:'English'},{value:'ar',label:'العربية (RTL)'}]} onChange={(e) => setLocaleDraft(e.target.value as any)} />
|
||||
</Field>
|
||||
@@ -195,6 +214,7 @@ export function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export function analyzeConversation(conversationId: string) {
|
||||
}
|
||||
|
||||
/** POST /api/ai/replies */
|
||||
export function generateReplies(conversationId: string) {
|
||||
return http.post<AiReplySuggestion[]>('/api/ai/replies', { conversationId })
|
||||
export function generateReplies(conversationId: string, targetLang = 'auto') {
|
||||
return http.post<AiReplySuggestion[]>('/api/ai/replies', { conversationId, targetLang })
|
||||
}
|
||||
|
||||
/** POST /api/ai/summarize */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http } from './client'
|
||||
import type { Conversation, Customer, Message, SendMessagePayload } from '@/types'
|
||||
import type { AiBackfillStatus, Conversation, Customer, Message, SendMessagePayload } from '@/types'
|
||||
|
||||
export type ConversationListItem = Conversation & { customer: Customer }
|
||||
|
||||
@@ -26,7 +26,20 @@ export function getConversations(query: ConversationQuery = {}) {
|
||||
|
||||
/** GET /api/conversations/:id */
|
||||
export function getConversation(id: string) {
|
||||
return http.get<{ conversation: Conversation; customer: Customer }>(`/api/conversations/${id}`)
|
||||
return http.get<{ conversation: Conversation; customer: Customer; aiBackfill?: AiBackfillStatus }>(`/api/conversations/${id}`)
|
||||
}
|
||||
|
||||
export function getConversationAiStatus(id: string) {
|
||||
return http.get<{
|
||||
backfill: AiBackfillStatus
|
||||
context: {
|
||||
status: string
|
||||
sourceMessageCount: number
|
||||
pendingMessages: number
|
||||
compressedAt: string | null
|
||||
memory: Conversation['aiContextMemory'] | null
|
||||
}
|
||||
}>(`/api/conversations/${id}/ai-status`)
|
||||
}
|
||||
|
||||
/** GET /api/conversations/:id/messages */
|
||||
@@ -80,6 +93,7 @@ export function sendMessage(
|
||||
payload: SendMessagePayload & {
|
||||
translatedText?: string
|
||||
autoTranslate?: boolean
|
||||
targetLang?: string
|
||||
idempotencyKey?: string
|
||||
clientMessageId?: string
|
||||
},
|
||||
@@ -136,7 +150,16 @@ export function runWhatsAppMessageAction(id: string, payload: WhatsAppMessageAct
|
||||
)
|
||||
}
|
||||
|
||||
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
|
||||
export function updateConversation(
|
||||
id: string,
|
||||
payload: {
|
||||
assigneeId?: string | null
|
||||
markRead?: boolean
|
||||
status?: string
|
||||
translationTargetLang?: string
|
||||
messageIntentEnabled?: boolean
|
||||
},
|
||||
) {
|
||||
return http.patch<Conversation>(`/api/conversations/${id}`, payload)
|
||||
}
|
||||
|
||||
|
||||
+72
-5
@@ -42,8 +42,8 @@
|
||||
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);
|
||||
radial-gradient(circle at 76% -12%, rgba(37, 99, 235, 0.055), transparent 28rem),
|
||||
linear-gradient(180deg, #ffffff 0, #f7f9fc 23rem);
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -70,10 +70,34 @@
|
||||
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)];
|
||||
@apply rounded-[14px] border border-slate-200/90 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.025),0_8px_24px_rgba(15,23,42,0.025)];
|
||||
}
|
||||
.eyebrow {
|
||||
@apply text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-700;
|
||||
@apply text-[11px] font-semibold tracking-[0.08em] text-primary-700;
|
||||
}
|
||||
.page-shell {
|
||||
@apply mx-auto w-full max-w-[1600px] px-4 py-5 md:px-7 md:py-7;
|
||||
}
|
||||
.page-section {
|
||||
@apply mt-5;
|
||||
}
|
||||
.section-heading {
|
||||
@apply mb-3 flex flex-wrap items-end justify-between gap-3;
|
||||
}
|
||||
.section-title {
|
||||
@apply text-[15px] font-semibold tracking-[-0.015em] text-slate-900;
|
||||
}
|
||||
.section-description {
|
||||
@apply mt-0.5 text-xs text-slate-500;
|
||||
}
|
||||
.quiet-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, .045) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, .045) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
.focus-surface {
|
||||
@apply border-primary-200/80 bg-primary-50/45 shadow-[0_8px_24px_-18px_rgba(37,99,235,.5),inset_0_0_0_1px_rgba(255,255,255,.8)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,11 +255,54 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.conversation-selected,
|
||||
.conversation-selected::after {
|
||||
.conversation-selected::after,
|
||||
.auth-message,
|
||||
.auth-translation-dot,
|
||||
.auth-floating-chip {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-corner-glow {
|
||||
background:
|
||||
radial-gradient(circle at 12% 12%, rgba(96, 165, 250, .16), transparent 28rem),
|
||||
radial-gradient(circle at 92% 86%, rgba(125, 211, 252, .12), transparent 24rem);
|
||||
}
|
||||
|
||||
.auth-preview-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, .055) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, .055) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
@keyframes auth-message-in {
|
||||
from { opacity: .62; transform: translate3d(-8px, 6px, 0); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0); }
|
||||
}
|
||||
|
||||
@keyframes auth-message-out {
|
||||
from { opacity: .62; transform: translate3d(8px, 6px, 0); }
|
||||
to { opacity: 1; transform: translate3d(0, 0, 0); }
|
||||
}
|
||||
|
||||
@keyframes auth-translation {
|
||||
0%, 17% { opacity: 0; transform: translateX(0); }
|
||||
24% { opacity: 1; }
|
||||
40% { opacity: 1; transform: translateX(92px); }
|
||||
48%, 100% { opacity: 0; transform: translateX(92px); }
|
||||
}
|
||||
|
||||
@keyframes auth-chip-float {
|
||||
0%, 100% { transform: translate3d(0, 0, 0); }
|
||||
50% { transform: translate3d(0, -4px, 0); }
|
||||
}
|
||||
|
||||
.auth-message-in { animation: auth-message-in .48s cubic-bezier(.2,.8,.2,1) both; }
|
||||
.auth-message-out { animation: auth-message-out .48s cubic-bezier(.2,.8,.2,1) .16s both; }
|
||||
.auth-translation-dot { animation: auth-translation 8s ease-in-out infinite; }
|
||||
.auth-floating-chip { animation: auth-chip-float 4s ease-in-out infinite; }
|
||||
|
||||
/* RTL 微调 */
|
||||
[dir='rtl'] .rtl-flip {
|
||||
transform: scaleX(-1);
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface AiReplySuggestion {
|
||||
targetLang: string
|
||||
reason: string
|
||||
knowledgeRefs: string[]
|
||||
knowledgeRefIds?: string[]
|
||||
expectedEffect: string
|
||||
cultureTip?: string
|
||||
}
|
||||
@@ -42,6 +43,13 @@ export interface TranslateResult {
|
||||
detectedLang: string
|
||||
terms: { source: string; target: string }[]
|
||||
historyItem?: TranslationHistoryItem
|
||||
meta?: {
|
||||
method: 'ai-contextual' | 'ai-direct'
|
||||
contextApplied: boolean
|
||||
contextMessageCount: number
|
||||
conversationSummaryApplied?: boolean
|
||||
model: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface TranslationHistoryItem {
|
||||
|
||||
@@ -46,6 +46,18 @@ export interface AiCustomerProfile {
|
||||
dealProbability?: number
|
||||
churnProbability?: number
|
||||
confidenceScore?: number
|
||||
intentBreakdown?: Array<{
|
||||
intent: string
|
||||
label: string
|
||||
count: number
|
||||
share: number
|
||||
}>
|
||||
lastMessageIntent?: {
|
||||
intent: string
|
||||
label: string
|
||||
urgency: string
|
||||
}
|
||||
profileVersion?: number
|
||||
evidence?: string[]
|
||||
}
|
||||
|
||||
@@ -54,6 +66,9 @@ export interface Customer {
|
||||
name: string
|
||||
company: string
|
||||
country: string
|
||||
countrySource?: 'manual' | 'account-profile' | 'phone-prefix' | 'unknown'
|
||||
countryConfidence?: number
|
||||
countryInferredAt?: string | null
|
||||
city?: string
|
||||
email: string
|
||||
phone: string
|
||||
|
||||
@@ -33,12 +33,17 @@ export interface KnowledgeItem {
|
||||
citedCount: number
|
||||
languages: string[]
|
||||
quality: 'good' | 'normal' | 'low'
|
||||
characterCount?: number
|
||||
chunkCount?: number
|
||||
indexedAt?: string
|
||||
parseMessage?: string
|
||||
}
|
||||
|
||||
export interface KnowledgeSearchHit {
|
||||
item: KnowledgeItem
|
||||
score: number
|
||||
snippet: string
|
||||
chunkIndex?: number
|
||||
}
|
||||
|
||||
export interface KnowledgeSearchResult {
|
||||
|
||||
@@ -32,7 +32,24 @@ export interface Message {
|
||||
translationCn?: string
|
||||
translationStatus?: 'pending' | 'completed' | 'failed'
|
||||
translatedAt?: string
|
||||
translationMethod?: 'ai-contextual' | 'ai-direct' | 'source'
|
||||
translationContextMessages?: number
|
||||
translationConversationSummaryApplied?: boolean
|
||||
translationModel?: string
|
||||
intentStatus?: 'pending' | 'completed' | 'failed'
|
||||
intentAnalysis?: {
|
||||
primary: string
|
||||
label: string
|
||||
confidence: number
|
||||
urgency: 'low' | 'medium' | 'high' | 'urgent'
|
||||
sentiment: 'positive' | 'neutral' | 'negative' | 'angry'
|
||||
entities: string[]
|
||||
rationale: string
|
||||
analyzedAt: string
|
||||
model: string
|
||||
}
|
||||
originalLang: string
|
||||
targetLang?: string
|
||||
status: MessageStatus
|
||||
createdAt: string
|
||||
isImportant?: boolean
|
||||
@@ -96,6 +113,37 @@ export interface Conversation {
|
||||
presence?: 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
|
||||
presenceUpdatedAt?: string
|
||||
lastSeenAt?: string
|
||||
translationTargetLang?: string
|
||||
messageIntentEnabled?: boolean
|
||||
aiContextStatus?: 'waiting' | 'queued' | 'compressing' | 'ready' | 'failed'
|
||||
aiContextSourceMessageCount?: number
|
||||
aiContextPendingMessages?: number
|
||||
aiContextCompressedAt?: string
|
||||
aiContextCompactedMessageIds?: string[]
|
||||
aiContextMemory?: {
|
||||
summary: string
|
||||
facts: string[]
|
||||
commitments: string[]
|
||||
products: string[]
|
||||
unresolved: string[]
|
||||
preferences: string[]
|
||||
updatedThroughMessageId: string
|
||||
sourceMessageCount: number
|
||||
updatedAt: string
|
||||
model: string
|
||||
version: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface AiBackfillStatus {
|
||||
status: 'not-started' | 'queued' | 'running' | 'completed' | 'failed'
|
||||
totalCount: number
|
||||
completedCount: number
|
||||
pendingCount: number
|
||||
progress: number
|
||||
startedAt?: string
|
||||
completedAt?: string | null
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
|
||||
@@ -142,6 +190,8 @@ export interface SendMessagePayload {
|
||||
text: string
|
||||
/** 已确认的中文参考译文,仅在内部消息记录中保存 */
|
||||
translationCn?: string
|
||||
/** auto 表示根据客户最近消息或客户画像自动选择目标语言 */
|
||||
targetLang?: string
|
||||
replyToId?: string
|
||||
replyPreview?: string
|
||||
type?: MessageType
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface AiSettings {
|
||||
autoGenerate: boolean
|
||||
requireConfirm: boolean
|
||||
sentimentAnalysis: boolean
|
||||
messageIntentAnalysis: boolean
|
||||
contextualTranslation: boolean
|
||||
riskAlert: boolean
|
||||
cultureCheck: boolean
|
||||
allowHistoryRef: boolean
|
||||
|
||||
Reference in New Issue
Block a user