manual save(2026-01-22 15:55)
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React from 'react'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { ChevronRight, Home as HomeIcon } from 'lucide-react'
|
import { ChevronRight, Home as HomeIcon } from 'lucide-react'
|
||||||
import { getLocaleFromPathname, stripLocalePrefix, withLocalePath } from '../lib/i18n'
|
import { getLocaleFromPathname, withLocalePath } from '../lib/i18n'
|
||||||
|
|
||||||
export type BreadcrumbItem = {
|
export type BreadcrumbItem = {
|
||||||
label: string
|
label: string
|
||||||
@@ -15,48 +15,40 @@ type BreadcrumbsProps = {
|
|||||||
|
|
||||||
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({ items, className }) => {
|
export const Breadcrumbs: React.FC<BreadcrumbsProps> = ({ items, className }) => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const locale = useMemo(() => getLocaleFromPathname(location.pathname), [location.pathname])
|
const locale = getLocaleFromPathname(location.pathname)
|
||||||
|
|
||||||
const homeLabel = locale === 'en' ? 'Home' : '首页'
|
|
||||||
const homeHref = withLocalePath(locale, '/')
|
|
||||||
|
|
||||||
const normalizedItems = useMemo(() => {
|
|
||||||
const prefix = stripLocalePrefix(location.pathname).startsWith('/') ? '' : '/'
|
|
||||||
void prefix
|
|
||||||
return items
|
|
||||||
}, [items, location.pathname])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label={locale === 'en' ? 'Breadcrumb' : '面包屑导航'} className={className}>
|
<nav aria-label={locale === 'en' ? 'Breadcrumb' : '面包屑导航'} className={className}>
|
||||||
<ol className="flex flex-wrap items-center gap-1 text-sm text-gray-600">
|
<ol className="flex flex-wrap items-center gap-1 text-sm text-white/85">
|
||||||
<li className="flex items-center gap-1">
|
<li className="flex items-center gap-1">
|
||||||
<Link
|
<Link
|
||||||
to={homeHref}
|
to={withLocalePath(locale, '/')}
|
||||||
className="inline-flex items-center gap-1 rounded-md px-2 py-1 hover:bg-white/60 hover:text-primary transition-colors"
|
className="inline-flex items-center gap-1 rounded-md px-2 py-1 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
aria-label={locale === 'en' ? 'Go to homepage' : '返回首页'}
|
aria-label={locale === 'en' ? 'Go to homepage' : '返回首页'}
|
||||||
>
|
>
|
||||||
<HomeIcon size={16} />
|
<HomeIcon size={16} />
|
||||||
<span>{homeLabel}</span>
|
<span>{locale === 'en' ? 'Home' : '首页'}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{normalizedItems.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const isLast = index === normalizedItems.length - 1
|
const isLast = index === items.length - 1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`${item.label}-${index}`}>
|
<React.Fragment key={`${item.label}-${index}`}>
|
||||||
<li aria-hidden="true" className="text-gray-400">
|
<li aria-hidden="true" className="text-white/50">
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-center">
|
<li className="flex items-center">
|
||||||
{item.href && !isLast ? (
|
{item.href && !isLast ? (
|
||||||
<Link
|
<Link
|
||||||
to={withLocalePath(locale, item.href)}
|
to={withLocalePath(locale, item.href)}
|
||||||
className="rounded-md px-2 py-1 hover:bg-white/60 hover:text-primary transition-colors"
|
className="rounded-md px-2 py-1 hover:bg-white/10 hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="px-2 py-1 text-gray-700 font-medium" aria-current={isLast ? 'page' : undefined}>
|
<span className="px-2 py-1 text-white font-medium" aria-current={isLast ? 'page' : undefined}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
719
src/pages/Assistant.tsx
Normal file
719
src/pages/Assistant.tsx
Normal file
@@ -0,0 +1,719 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
CheckCircle,
|
||||||
|
ClipboardList,
|
||||||
|
Loader2,
|
||||||
|
MessageSquare,
|
||||||
|
Phone,
|
||||||
|
Send,
|
||||||
|
Sparkles,
|
||||||
|
User,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Header } from '../components/Header'
|
||||||
|
import { Footer } from '../components/Footer'
|
||||||
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||||
|
import { FormSubmissions, Forms, SearchResults } from '../clientsdk/sdk.gen'
|
||||||
|
import { createClient } from '../clientsdk/client'
|
||||||
|
import { customQuerySerializer } from '../clientsdk/querySerializer'
|
||||||
|
import { API_URL, TENANT_API_KEY, TENANT_SLUG } from '../config'
|
||||||
|
import { usePageMeta } from '../hooks/usePageMeta'
|
||||||
|
import { PAGE_META } from '../lib/constants'
|
||||||
|
import { getLocaleFromPathname } from '../lib/i18n'
|
||||||
|
|
||||||
|
type ChatRole = 'user' | 'assistant'
|
||||||
|
|
||||||
|
type ChatMessage = {
|
||||||
|
id: string
|
||||||
|
role: ChatRole
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeadFormData = {
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
company: string
|
||||||
|
demand: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LeadFormErrors = Partial<Record<keyof LeadFormData, string>>
|
||||||
|
|
||||||
|
type Locale = 'zh' | 'en'
|
||||||
|
|
||||||
|
const client = createClient({
|
||||||
|
baseUrl: API_URL,
|
||||||
|
querySerializer: customQuerySerializer,
|
||||||
|
headers: {
|
||||||
|
'X-Tenant-Slug': TENANT_SLUG,
|
||||||
|
'X-API-Key': TENANT_API_KEY,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function uuid() {
|
||||||
|
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value: string) {
|
||||||
|
return value.trim().replace(/\s+/g, ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPhone(phone: string) {
|
||||||
|
return /^1\d{10}$/.test(phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtml(html: string) {
|
||||||
|
const tmp = document.createElement('div')
|
||||||
|
tmp.innerHTML = html
|
||||||
|
return (tmp.textContent || tmp.innerText || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFallbackAnswer(locale: Locale, question: string) {
|
||||||
|
const q = question.replace(/\s+/g, '')
|
||||||
|
|
||||||
|
if (locale === 'en') {
|
||||||
|
return [
|
||||||
|
"I couldn't find an exact match in the knowledge base.",
|
||||||
|
'Please leave your phone number and consulting needs, and our team will follow up.',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/北京.*高新.*(认定|企业)|高新企业认定|高新技术企业/.test(q)) {
|
||||||
|
return [
|
||||||
|
'关于“北京高新技术企业认定”的常见条件(示例整理,具体以最新政策与主管部门口径为准):',
|
||||||
|
'',
|
||||||
|
'1)基本门槛',
|
||||||
|
'- 企业依法注册、正常经营,近年无重大违法违规记录。',
|
||||||
|
'- 主要产品/服务属于国家重点支持的高新技术领域。',
|
||||||
|
'',
|
||||||
|
'2)核心评审维度(常见)',
|
||||||
|
'- 知识产权:发明/实用新型/软著等数量、质量与关联度。',
|
||||||
|
'- 科技人员占比:研发与技术人员结构、社保/劳动关系等合规性。',
|
||||||
|
'- 研发投入:近三年研发费用归集口径与占比。',
|
||||||
|
'- 高新收入占比:高新技术产品(服务)收入与主营结构。',
|
||||||
|
'- 创新能力:成果转化、组织管理、成长性等。',
|
||||||
|
'',
|
||||||
|
'3)材料准备建议(常见清单)',
|
||||||
|
'- 企业基本资料:营业执照、章程、财务报表/审计等。',
|
||||||
|
'- 知识产权与研发项目:证书、研发立项/结题、费用归集凭证。',
|
||||||
|
'- 产品/服务说明:技术领域、核心技术说明、收入佐证材料。',
|
||||||
|
'',
|
||||||
|
'如果你方便补充:行业 + 主要产品/服务 + 已有知识产权情况,我可以按你企业现状给出更具体的材料清单与推进路径。',
|
||||||
|
'',
|
||||||
|
'若需要人工进一步对接,请在右侧“留资”填写手机号(必填),我们会尽快联系。',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'我暂时无法在知识库中定位到完全匹配的答案。',
|
||||||
|
'你可以补充更多背景(行业/地区/需求细节),或者直接在“留资”里提交手机号,我们安排人工跟进。',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchKnowledge(question: string) {
|
||||||
|
const response = await SearchResults.listSearchResults({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
limit: 5,
|
||||||
|
depth: 2,
|
||||||
|
where: {
|
||||||
|
title: {
|
||||||
|
contains: question,
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return ((response as any)?.data?.docs ?? []) as any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveLeadFormId() {
|
||||||
|
const res = await Forms.listForms({
|
||||||
|
client,
|
||||||
|
query: {
|
||||||
|
limit: 100,
|
||||||
|
depth: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const forms = ((res as any)?.data?.docs ?? []) as any[]
|
||||||
|
const target = forms.find((f) => {
|
||||||
|
const title = String(f?.title || '')
|
||||||
|
return /AI\s*智能助手|诚裕智能客服|AI Assistant|Assistant Lead|留资/i.test(title)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (target?.id as string | undefined) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Assistant: React.FC = () => {
|
||||||
|
const locale = useMemo(() => getLocaleFromPathname(window.location.pathname), [])
|
||||||
|
|
||||||
|
usePageMeta({
|
||||||
|
title: locale === 'en' ? 'AI Assistant' : PAGE_META.assistant.title,
|
||||||
|
description: PAGE_META.assistant.description,
|
||||||
|
locale,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<'chat' | 'lead'>('chat')
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>(() => [
|
||||||
|
{
|
||||||
|
id: uuid(),
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
locale === 'en'
|
||||||
|
? "Hi, I'm Chengyu AI Customer Service. Ask me anything, or leave your phone number for a callback."
|
||||||
|
: '你好,我是“诚裕智能客服”。你可以直接提问,我会基于知识库为你快速解答;如需人工进一步对接,也可以在“留资”提交手机号。',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [isReplying, setIsReplying] = useState(false)
|
||||||
|
|
||||||
|
const [leadFormId, setLeadFormId] = useState<string | null>(null)
|
||||||
|
const [leadFormLoading, setLeadFormLoading] = useState(true)
|
||||||
|
|
||||||
|
const [lead, setLead] = useState<LeadFormData>({ name: '', phone: '', company: '', demand: '' })
|
||||||
|
const [leadErrors, setLeadErrors] = useState<LeadFormErrors>({})
|
||||||
|
const [leadSubmitting, setLeadSubmitting] = useState(false)
|
||||||
|
const [leadSubmitted, setLeadSubmitted] = useState(false)
|
||||||
|
const [leadSubmitError, setLeadSubmitError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLeadFormLoading(true)
|
||||||
|
const id = await resolveLeadFormId()
|
||||||
|
setLeadFormId(id)
|
||||||
|
} catch {
|
||||||
|
setLeadFormId(null)
|
||||||
|
} finally {
|
||||||
|
setLeadFormLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' })
|
||||||
|
}, [messages, isReplying])
|
||||||
|
|
||||||
|
const exampleQuestions = useMemo(
|
||||||
|
() =>
|
||||||
|
locale === 'en'
|
||||||
|
? [
|
||||||
|
'What are typical criteria for high-tech enterprise recognition in Beijing?',
|
||||||
|
'What materials are usually required?',
|
||||||
|
]
|
||||||
|
: ['北京高新企业认定需要哪些条件?', '高新企业申报通常需要准备哪些材料?'],
|
||||||
|
[locale],
|
||||||
|
)
|
||||||
|
|
||||||
|
const pushMessage = (role: ChatRole, content: string) => {
|
||||||
|
setMessages((prev) => [...prev, { id: uuid(), role, content }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const replyTo = async (rawQuestion: string) => {
|
||||||
|
const question = normalizeText(rawQuestion)
|
||||||
|
if (!question) return
|
||||||
|
|
||||||
|
pushMessage('user', question)
|
||||||
|
setInput('')
|
||||||
|
setIsReplying(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const docs = await searchKnowledge(question)
|
||||||
|
const top = docs[0]
|
||||||
|
|
||||||
|
if (docs.length > 0 && top?.doc?.value) {
|
||||||
|
const post = top.doc.value
|
||||||
|
const title = post?.title || top?.title
|
||||||
|
const excerpt = stripHtml(post?.content_html || '')
|
||||||
|
|
||||||
|
const answer =
|
||||||
|
locale === 'en'
|
||||||
|
? [
|
||||||
|
`Based on the knowledge base: "${title}"`,
|
||||||
|
'',
|
||||||
|
excerpt ? excerpt.slice(0, 420) + (excerpt.length > 420 ? '…' : '') : '',
|
||||||
|
'',
|
||||||
|
'If you want a human follow-up, please leave your phone number in the lead form.',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
: [
|
||||||
|
`已为你从知识库匹配到:${title}`,
|
||||||
|
'',
|
||||||
|
excerpt ? excerpt.slice(0, 320) + (excerpt.length > 320 ? '…' : '') : '(该条目暂无摘要内容)',
|
||||||
|
'',
|
||||||
|
'若需要人工进一步沟通,建议在“留资”提交手机号(必填),我们会尽快联系。',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
pushMessage('assistant', answer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallback = buildFallbackAnswer(locale, question)
|
||||||
|
pushMessage('assistant', fallback)
|
||||||
|
setActiveTab('lead')
|
||||||
|
} catch {
|
||||||
|
pushMessage(
|
||||||
|
'assistant',
|
||||||
|
locale === 'en'
|
||||||
|
? 'Sorry, I cannot access the knowledge base right now. Please leave your phone number and we will contact you.'
|
||||||
|
: '抱歉,我暂时无法访问知识库。请在“留资”提交手机号,我们会尽快联系你。',
|
||||||
|
)
|
||||||
|
setActiveTab('lead')
|
||||||
|
} finally {
|
||||||
|
setIsReplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (isReplying) return
|
||||||
|
await replyTo(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateLead = () => {
|
||||||
|
const next: LeadFormErrors = {}
|
||||||
|
|
||||||
|
if (!lead.phone.trim()) next.phone = locale === 'en' ? 'Phone is required' : '手机号为必填项'
|
||||||
|
else if (!isValidPhone(lead.phone.trim())) next.phone = locale === 'en' ? 'Invalid 11-digit phone' : '请输入有效的 11 位手机号'
|
||||||
|
|
||||||
|
if (lead.demand.trim().length < 6) next.demand = locale === 'en' ? 'Please provide more details' : '请简单描述需求(至少 6 个字符)'
|
||||||
|
|
||||||
|
setLeadErrors(next)
|
||||||
|
return Object.keys(next).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitLead = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLeadSubmitError(null)
|
||||||
|
|
||||||
|
if (!validateLead()) return
|
||||||
|
|
||||||
|
setLeadSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!leadFormId) {
|
||||||
|
const cacheKey = 'chengyu_leads_local'
|
||||||
|
const record = {
|
||||||
|
...lead,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
source: 'assistant',
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = JSON.parse(localStorage.getItem(cacheKey) || '[]')
|
||||||
|
localStorage.setItem(cacheKey, JSON.stringify([record, ...existing]))
|
||||||
|
|
||||||
|
setLeadSubmitted(true)
|
||||||
|
setLead({ name: '', phone: '', company: '', demand: '' })
|
||||||
|
setTimeout(() => setLeadSubmitted(false), 3500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await FormSubmissions.createFormSubmission({
|
||||||
|
client,
|
||||||
|
body: {
|
||||||
|
form: leadFormId,
|
||||||
|
submissionData: [
|
||||||
|
{ field: 'name', value: lead.name.trim() },
|
||||||
|
{ field: 'phone', value: lead.phone.trim() },
|
||||||
|
{ field: 'company', value: lead.company.trim() },
|
||||||
|
{ field: 'demand', value: lead.demand.trim() },
|
||||||
|
{ field: 'source', value: 'AI智能助手' },
|
||||||
|
].filter((item) => item.value),
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
setLeadSubmitted(true)
|
||||||
|
setLead({ name: '', phone: '', company: '', demand: '' })
|
||||||
|
setTimeout(() => setLeadSubmitted(false), 3500)
|
||||||
|
} catch {
|
||||||
|
setLeadSubmitError(locale === 'en' ? 'Submission failed. Please try again.' : '提交失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
setLeadSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div className="min-h-screen bg-background" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.4 }}>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section className="pt-28 pb-14 bg-gradient-to-br from-primary to-primary-light">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<motion.div className="text-white" initial={{ opacity: 0, y: 26 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.55 }}>
|
||||||
|
<Breadcrumbs items={[{ label: locale === 'en' ? 'AI Assistant' : 'AI智能助手', href: '/assistant' }]} className="mb-5" />
|
||||||
|
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold tracking-tight">
|
||||||
|
{locale === 'en' ? 'Chengyu AI Customer Service' : '诚裕智能客服'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-4 text-lg text-white/85 max-w-2xl leading-relaxed">
|
||||||
|
{locale === 'en'
|
||||||
|
? 'Knowledge-base powered Q&A, plus a fast lead channel for consulting.'
|
||||||
|
: '基于知识库的自然语言问答,同时也是诚裕集团重要的留资渠道。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 border border-white/20 text-sm">
|
||||||
|
<Sparkles size={16} />
|
||||||
|
{locale === 'en' ? 'Fast response' : '快速响应'}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/10 border border-white/20 text-sm">
|
||||||
|
<ClipboardList size={16} />
|
||||||
|
{locale === 'en' ? 'Lead capture' : '留资转化'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="py-14 -mt-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid lg:grid-cols-5 gap-8">
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 bg-white/70">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
|
||||||
|
<Bot size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-primary-dark">{locale === 'en' ? 'Chengyu AI Customer Service' : '诚裕智能客服'}</div>
|
||||||
|
<div className="text-xs text-gray-500">{locale === 'en' ? 'Knowledge-base powered answers' : '基于知识库内容检索与整理'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('chat')}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'chat' ? 'bg-primary/5 text-primary' : 'text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{locale === 'en' ? 'Chat' : '对话'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('lead')}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
activeTab === 'lead' ? 'bg-primary/5 text-primary' : 'text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{locale === 'en' ? 'Leave info' : '留资'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'chat' ? (
|
||||||
|
<>
|
||||||
|
<div ref={listRef} className="h-[460px] overflow-y-auto px-6 py-6 bg-gradient-to-b from-white to-background">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<div key={m.id} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div
|
||||||
|
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm border ${
|
||||||
|
m.role === 'user'
|
||||||
|
? 'bg-primary text-white border-primary/20'
|
||||||
|
: 'bg-white text-gray-800 border-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<pre className="whitespace-pre-wrap font-sans">{m.content}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isReplying && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm border bg-white text-gray-800 border-gray-100 flex items-center gap-2">
|
||||||
|
<Loader2 className="animate-spin" size={16} />
|
||||||
|
<span>{locale === 'en' ? 'Thinking…' : '正在检索并整理…'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="text-xs text-gray-500 mb-2">{locale === 'en' ? 'Try examples:' : '试试这些问题:'}</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{exampleQuestions.map((q) => (
|
||||||
|
<button
|
||||||
|
key={q}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void replyTo(q)}
|
||||||
|
className="text-left px-3 py-2 rounded-xl bg-white border border-gray-100 hover:border-primary/30 hover:bg-primary/5 transition-colors text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-t border-gray-100 bg-white">
|
||||||
|
<div className="flex items-end gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label htmlFor="assistantInput" className="sr-only">
|
||||||
|
{locale === 'en' ? 'Message input' : '输入问题'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="assistantInput"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
placeholder={locale === 'en' ? 'Type your question…' : '请输入你的问题…'}
|
||||||
|
rows={2}
|
||||||
|
className="w-full resize-none rounded-xl border border-gray-200 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
void handleSend()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSend()}
|
||||||
|
disabled={!normalizeText(input) || isReplying}
|
||||||
|
className={`inline-flex items-center justify-center w-12 h-12 rounded-xl text-white transition-colors ${
|
||||||
|
!normalizeText(input) || isReplying ? 'bg-gray-300 cursor-not-allowed' : 'bg-primary hover:bg-primary-light'
|
||||||
|
}`}
|
||||||
|
aria-label={locale === 'en' ? 'Send' : '发送'}
|
||||||
|
>
|
||||||
|
<Send size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
|
{locale === 'en' ? 'Tip: Enter to send, Shift+Enter for a new line.' : '提示:Enter 发送,Shift+Enter 换行。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="px-6 py-6 bg-white">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-accent/15 text-accent-dark flex items-center justify-center">
|
||||||
|
<Phone size={18} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-lg font-semibold text-primary-dark">
|
||||||
|
{locale === 'en' ? 'Leave your information for a callback' : '填写信息,便于我们联系你'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-gray-600 leading-relaxed">
|
||||||
|
{locale === 'en'
|
||||||
|
? 'Phone number is required. The ops team can process submissions in the backend.'
|
||||||
|
: '手机号为必填项;提交后,后台运营人员可及时查看并处理(可在运营平台表单里配置邮件通知)。'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{leadSubmitted && (
|
||||||
|
<div className="mt-5 p-4 rounded-xl border border-green-200 bg-green-50 flex items-start gap-3">
|
||||||
|
<CheckCircle size={20} className="text-green-600 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-green-800">{locale === 'en' ? 'Submitted successfully' : '提交成功'}</div>
|
||||||
|
<div className="text-sm text-green-700 mt-1">
|
||||||
|
{leadFormId
|
||||||
|
? locale === 'en'
|
||||||
|
? 'Our team will contact you shortly.'
|
||||||
|
: '已收到你的信息,我们会尽快与你联系。'
|
||||||
|
: locale === 'en'
|
||||||
|
? 'Backend form not configured yet; saved locally for now.'
|
||||||
|
: '暂未找到后台表单配置,本次留资已临时保存在浏览器本地(请尽快在运营平台创建表单以正式留存)。'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{leadSubmitError && (
|
||||||
|
<div className="mt-5 p-4 rounded-xl border border-red-200 bg-red-50 text-sm text-red-700">
|
||||||
|
{leadSubmitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={submitLead} className="mt-6 space-y-5">
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="leadName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{locale === 'en' ? 'Name' : '姓名'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<User size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
id="leadName"
|
||||||
|
value={lead.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLead((p) => ({ ...p, name: e.target.value }))
|
||||||
|
if (leadErrors.name) setLeadErrors((p) => ({ ...p, name: undefined }))
|
||||||
|
}}
|
||||||
|
className="w-full rounded-xl border border-gray-200 pl-10 pr-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
placeholder={locale === 'en' ? 'Your name (optional)' : '你的姓名(选填)'}
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{leadErrors.name && <p className="mt-1 text-xs text-red-600">{leadErrors.name}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="leadPhone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{locale === 'en' ? 'Phone (required)' : '手机号(必填)'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
id="leadPhone"
|
||||||
|
value={lead.phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLead((p) => ({ ...p, phone: e.target.value }))
|
||||||
|
if (leadErrors.phone) setLeadErrors((p) => ({ ...p, phone: undefined }))
|
||||||
|
}}
|
||||||
|
className={`w-full rounded-xl border pl-10 pr-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 ${
|
||||||
|
leadErrors.phone ? 'border-red-300 focus:border-red-500' : 'border-gray-200 focus:border-primary'
|
||||||
|
}`}
|
||||||
|
placeholder={locale === 'en' ? '11-digit mobile' : '请输入 11 位手机号'}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="tel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{leadErrors.phone && <p className="mt-1 text-xs text-red-600">{leadErrors.phone}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="leadCompany" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{locale === 'en' ? 'Company' : '公司'}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MessageSquare size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
id="leadCompany"
|
||||||
|
value={lead.company}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLead((p) => ({ ...p, company: e.target.value }))
|
||||||
|
if (leadErrors.company) setLeadErrors((p) => ({ ...p, company: undefined }))
|
||||||
|
}}
|
||||||
|
className="w-full rounded-xl border border-gray-200 pl-10 pr-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
||||||
|
placeholder={locale === 'en' ? 'Company name (optional)' : '公司名称(选填)'}
|
||||||
|
autoComplete="organization"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{leadErrors.company && <p className="mt-1 text-xs text-red-600">{leadErrors.company}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="leadDemand" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
{locale === 'en' ? 'Consulting needs' : '咨询需求'}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="leadDemand"
|
||||||
|
value={lead.demand}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLead((p) => ({ ...p, demand: e.target.value }))
|
||||||
|
if (leadErrors.demand) setLeadErrors((p) => ({ ...p, demand: undefined }))
|
||||||
|
}}
|
||||||
|
rows={4}
|
||||||
|
className={`w-full resize-none rounded-xl border px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 ${
|
||||||
|
leadErrors.demand ? 'border-red-300 focus:border-red-500' : 'border-gray-200 focus:border-primary'
|
||||||
|
}`}
|
||||||
|
placeholder={locale === 'en' ? 'What do you want to consult about?' : '请描述你想咨询的问题(如:高新申报、体系搭建、审计口径…)'}
|
||||||
|
/>
|
||||||
|
{leadErrors.demand && <p className="mt-1 text-xs text-red-600">{leadErrors.demand}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={leadSubmitting}
|
||||||
|
className={`w-full rounded-xl py-3.5 text-sm font-medium text-white transition-colors inline-flex items-center justify-center gap-2 ${
|
||||||
|
leadSubmitting ? 'bg-gray-400 cursor-not-allowed' : 'bg-primary hover:bg-primary-light'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{leadSubmitting ? <Loader2 className="animate-spin" size={18} /> : <Send size={18} />}
|
||||||
|
{leadSubmitting ? (locale === 'en' ? 'Submitting…' : '提交中…') : locale === 'en' ? 'Submit' : '提交留资'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 leading-relaxed">
|
||||||
|
{leadFormLoading ? (
|
||||||
|
<span>{locale === 'en' ? 'Loading backend form configuration…' : '正在加载后台表单配置…'}</span>
|
||||||
|
) : leadFormId ? (
|
||||||
|
<span>
|
||||||
|
{locale === 'en'
|
||||||
|
? 'This submission will be recorded in the operations platform.'
|
||||||
|
: '本次留资将记录到运营平台,便于运营人员及时查看处理。'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{locale === 'en'
|
||||||
|
? 'Backend form not found. Please create a form titled "AI Assistant Lead".'
|
||||||
|
: '未找到后台留资表单。建议在运营平台创建标题包含“AI智能助手/留资”的表单,并配置通知(邮件/Webhook)以实现“及时提醒”。'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="lg:col-span-2 space-y-6">
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-primary-dark flex items-center gap-2">
|
||||||
|
<Sparkles size={18} className="text-accent-dark" />
|
||||||
|
{locale === 'en' ? 'How it works' : '如何运行'}
|
||||||
|
</h2>
|
||||||
|
<ul className="mt-4 space-y-3 text-sm text-gray-700">
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<span className="mt-0.5 w-6 h-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">1</span>
|
||||||
|
<span>{locale === 'en' ? 'Ops team maintains the knowledge base in the backend.' : '运营平台导入/维护知识库内容(文章、条目、资料等)。'}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<span className="mt-0.5 w-6 h-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">2</span>
|
||||||
|
<span>{locale === 'en' ? 'Users ask questions; assistant searches and summarizes.' : '客户在前台自然语言提问,助手检索知识库并结构化回答。'}</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<span className="mt-0.5 w-6 h-6 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">3</span>
|
||||||
|
<span>{locale === 'en' ? 'If manual follow-up is needed, guide users to leave contact info.' : '当无法确定答案或需进一步沟通时,引导客户留资(手机号必填并校验)。'}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-primary-dark">{locale === 'en' ? 'Lead channel focus' : '留资转化重点'}</h2>
|
||||||
|
<p className="mt-3 text-sm text-gray-700 leading-relaxed">
|
||||||
|
{locale === 'en'
|
||||||
|
? 'For Chengyu Group, the assistant is also an important acquisition channel.'
|
||||||
|
: '对诚裕集团而言,该助手不仅用于快速响应,更是重要的获客与留资渠道。'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-3">
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-background p-4">
|
||||||
|
<div className="text-sm font-medium text-primary-dark">{locale === 'en' ? 'Required field' : '必填字段'}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-600">{locale === 'en' ? 'Phone number (11 digits) with basic validation.' : '手机号(11 位)必填并进行基础校验。'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-background p-4">
|
||||||
|
<div className="text-sm font-medium text-primary-dark">{locale === 'en' ? 'Timely processing' : '及时提醒'}</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-600">
|
||||||
|
{locale === 'en'
|
||||||
|
? 'Configure email/webhook notifications in the ops platform form settings.'
|
||||||
|
: '建议在运营平台表单里配置邮件/Webhook 通知,确保运营人员及时处理。'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Assistant
|
||||||
Reference in New Issue
Block a user