新增访客聊天Widget组件、Admin端布局框架及路由
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import { Layout } from 'antd'
|
||||||
|
import AdminSidebar from './AdminSidebar'
|
||||||
|
|
||||||
|
const { Content } = Layout
|
||||||
|
|
||||||
|
const AdminLayout = () => {
|
||||||
|
return (
|
||||||
|
<Layout className="h-screen">
|
||||||
|
<AdminSidebar />
|
||||||
|
<Layout>
|
||||||
|
<Content className="overflow-auto bg-neutral-50 p-6">
|
||||||
|
<Outlet />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminLayout
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { Layout, Menu } from 'antd'
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
GiftOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
|
const { Sider } = Layout
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '运营概览' },
|
||||||
|
{ key: '/admin/tenants', icon: <TeamOutlined />, label: '租户管理' },
|
||||||
|
{ key: '/admin/plans', icon: <GiftOutlined />, label: '套餐管理' },
|
||||||
|
{ key: '/admin/ops', icon: <ToolOutlined />, label: '系统运维' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const AdminSidebar = () => {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/admin/dashboard'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sider width={220} className="!bg-white border-r border-neutral-200">
|
||||||
|
<div className="h-14 flex items-center px-5 border-b border-neutral-100">
|
||||||
|
<DashboardOutlined className="text-lg text-[#2563eb] mr-2.5" />
|
||||||
|
<span className="text-base font-semibold text-neutral-900">客服云管理</span>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[selectedKey]}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={({ key }) => navigate(key)}
|
||||||
|
className="border-e-0 mt-2"
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminSidebar
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import VisitorChat from '@/widgets/VisitorChat'
|
||||||
|
|
||||||
|
const WidgetPreview = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-100 flex flex-col">
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-800 mb-2">示例网站页面</h1>
|
||||||
|
<p className="text-neutral-400">访客聊天 Widget 已嵌入右下角,点击气泡按钮开始对话</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VisitorChat />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WidgetPreview
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const AdminDashboard = () => (
|
||||||
|
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
|
||||||
|
<div><div className="text-lg mb-1">运营概览</div><div className="text-sm">开发中...</div></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
export default AdminDashboard
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const Ops = () => (
|
||||||
|
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
|
||||||
|
<div><div className="text-lg mb-1">系统运维</div><div className="text-sm">开发中...</div></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
export default Ops
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const Plans = () => (
|
||||||
|
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
|
||||||
|
<div><div className="text-lg mb-1">套餐管理</div><div className="text-sm">开发中...</div></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
export default Plans
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const Tenants = () => (
|
||||||
|
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
|
||||||
|
<div><div className="text-lg mb-1">租户管理</div><div className="text-sm">开发中...</div></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
export default Tenants
|
||||||
@@ -3,12 +3,18 @@ import { Navigate, createBrowserRouter } from 'react-router-dom'
|
|||||||
import { Spin } from 'antd'
|
import { Spin } from 'antd'
|
||||||
|
|
||||||
const AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
|
const AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
|
||||||
|
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout'))
|
||||||
const Dashboard = lazy(() => import('@/pages/agent/Dashboard'))
|
const Dashboard = lazy(() => import('@/pages/agent/Dashboard'))
|
||||||
const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
|
const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
|
||||||
const Customers = lazy(() => import('@/pages/agent/Customers'))
|
const Customers = lazy(() => import('@/pages/agent/Customers'))
|
||||||
const Knowledge = lazy(() => import('@/pages/agent/Knowledge'))
|
const Knowledge = lazy(() => import('@/pages/agent/Knowledge'))
|
||||||
const Statistics = lazy(() => import('@/pages/agent/Statistics'))
|
const Statistics = lazy(() => import('@/pages/agent/Statistics'))
|
||||||
const Settings = lazy(() => import('@/pages/agent/Settings'))
|
const Settings = lazy(() => import('@/pages/agent/Settings'))
|
||||||
|
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
|
||||||
|
const Tenants = lazy(() => import('@/pages/admin/Tenants'))
|
||||||
|
const Plans = lazy(() => import('@/pages/admin/Plans'))
|
||||||
|
const Ops = lazy(() => import('@/pages/admin/Ops'))
|
||||||
|
const WidgetPreview = lazy(() => import('@/pages/WidgetPreview'))
|
||||||
|
|
||||||
const loading = (
|
const loading = (
|
||||||
<div className="h-full flex items-center justify-center">
|
<div className="h-full flex items-center justify-center">
|
||||||
@@ -21,9 +27,20 @@ function Lazy({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/widget/preview',
|
||||||
|
element: <Lazy><WidgetPreview /></Lazy>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
element: <div>管理端</div>,
|
element: <Lazy><AdminLayout /></Lazy>,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Navigate to="/admin/dashboard" replace /> },
|
||||||
|
{ path: 'dashboard', element: <Lazy><AdminDashboard /></Lazy> },
|
||||||
|
{ path: 'tenants', element: <Lazy><Tenants /></Lazy> },
|
||||||
|
{ path: 'plans', element: <Lazy><Plans /></Lazy> },
|
||||||
|
{ path: 'ops', element: <Lazy><Ops /></Lazy> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
interface Message {
|
||||||
|
id: string
|
||||||
|
sender: 'visitor' | 'agent'
|
||||||
|
content: string
|
||||||
|
time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialMessages: Message[] = [
|
||||||
|
{ id: '1', sender: 'agent', content: '您好!欢迎咨询客服云,请问有什么可以帮您的?', time: '10:30' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { CloseOutlined, MessageOutlined, SmileOutlined, PaperClipOutlined, SendOutlined, StarFilled } from '@ant-design/icons'
|
||||||
|
|
||||||
|
const VisitorChat = () => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [messages, setMessages] = useState<Message[]>(initialMessages)
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [typing, setTyping] = useState(false)
|
||||||
|
const [showRating, setShowRating] = useState(false)
|
||||||
|
const [rated, setRated] = useState(false)
|
||||||
|
|
||||||
|
const sendMessage = (text: string) => {
|
||||||
|
if (!text.trim()) return
|
||||||
|
const msg: Message = { id: Date.now().toString(), sender: 'visitor', content: text, time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }
|
||||||
|
setMessages(prev => [...prev, msg])
|
||||||
|
setInput('')
|
||||||
|
setTyping(true)
|
||||||
|
setTimeout(() => {
|
||||||
|
setTyping(false)
|
||||||
|
const reply: Message = { id: (Date.now() + 1).toString(), sender: 'agent', content: '感谢您的咨询,客服正在为您处理中...', time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }
|
||||||
|
setMessages(prev => [...prev, reply])
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
if (!rated) {
|
||||||
|
setShowRating(true)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 悬浮按钮 */}
|
||||||
|
{!open && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setOpen(true); setShowRating(false) }}
|
||||||
|
className="fixed right-5 bottom-5 w-14 h-14 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow-lg flex items-center justify-center transition-all hover:scale-110 z-50"
|
||||||
|
>
|
||||||
|
<MessageOutlined className="text-xl" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 聊天窗口 */}
|
||||||
|
{open && (
|
||||||
|
<div className="fixed right-5 bottom-5 w-[400px] h-[600px] bg-white rounded-xl shadow-2xl border border-neutral-200 flex flex-col z-50 overflow-hidden">
|
||||||
|
{/* 顶部 */}
|
||||||
|
<div className="h-14 px-4 flex items-center justify-between bg-gradient-to-r from-blue-500 to-blue-600 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center">
|
||||||
|
<span className="text-white text-sm font-medium">云</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-white">客服云</div>
|
||||||
|
<div className="text-xs text-white/70">{typing ? '正在输入...' : '在线'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CloseOutlined className="text-white cursor-pointer hover:text-white/80" onClick={handleEnd} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息区域 */}
|
||||||
|
<div className="flex-1 overflow-auto p-4 space-y-3 bg-neutral-50">
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} className={`flex ${msg.sender === 'visitor' ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${msg.sender === 'visitor' ? 'bg-blue-500 text-white' : 'bg-white text-neutral-700 border border-neutral-200'}`}>
|
||||||
|
{msg.content}
|
||||||
|
<div className={`text-xs mt-1 ${msg.sender === 'visitor' ? 'text-white/60' : 'text-neutral-400'}`}>{msg.time}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{typing && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-white rounded-lg px-3 py-2 border border-neutral-200">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-neutral-300 animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-neutral-300 animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-neutral-300 animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷问题 */}
|
||||||
|
{messages.length <= 1 && (
|
||||||
|
<div className="px-4 py-2 border-t border-neutral-100 flex-shrink-0">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{quickQuestions.map((q, i) => (
|
||||||
|
<button key={i} onClick={() => sendMessage(q)} className="text-xs px-2.5 py-1 rounded-full bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors border border-blue-100">
|
||||||
|
{q}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入框 */}
|
||||||
|
<div className="p-3 border-t border-neutral-100 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2 bg-neutral-50 rounded-lg px-3 py-1.5">
|
||||||
|
<SmileOutlined className="text-neutral-300 cursor-pointer hover:text-neutral-500" />
|
||||||
|
<PaperClipOutlined className="text-neutral-300 cursor-pointer hover:text-neutral-500" />
|
||||||
|
<input
|
||||||
|
className="flex-1 bg-transparent outline-none text-sm text-neutral-700 placeholder:text-neutral-400"
|
||||||
|
placeholder="输入您的问题..."
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { sendMessage(input) } }}
|
||||||
|
/>
|
||||||
|
<SendOutlined className="text-blue-500 cursor-pointer hover:text-blue-600" onClick={() => sendMessage(input)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 满意度弹窗 */}
|
||||||
|
{showRating && (
|
||||||
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
|
||||||
|
<div className="bg-white rounded-xl p-6 mx-8 w-full max-w-xs text-center">
|
||||||
|
<div className="text-lg font-semibold text-neutral-800 mb-1">本次服务如何?</div>
|
||||||
|
<div className="text-sm text-neutral-400 mb-4">请对我们的服务进行评价</div>
|
||||||
|
<div className="flex justify-center gap-1.5 mb-4">
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<StarFilled
|
||||||
|
key={star}
|
||||||
|
className="text-2xl cursor-pointer text-neutral-200 hover:text-yellow-400 transition-colors"
|
||||||
|
onClick={() => { setRated(true); setShowRating(false) }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600">跳过</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VisitorChat
|
||||||
Reference in New Issue
Block a user