添加消息发送API、丰富种子对话数据、Dashboard支持真实消息发送和客户名显示

This commit is contained in:
yml2213
2026-07-14 11:45:57 +08:00
parent caf14e8e52
commit 616b1bcaa2
4 changed files with 198 additions and 96 deletions
+25 -4
View File
@@ -95,11 +95,32 @@ func seed() {
}
model.DB.Create(&sessions)
// Messages
// Messages - 模拟真实对话
messages := []model.Message{
{SessionID: sessions[0].ID, SenderType: "visitor", SenderID: &customers[0].ID, Content: "你好,我的订单怎么还没发货?", Type: "text", Seq: 1, SentAt: now},
{SessionID: sessions[0].ID, SenderType: "agent", SenderID: &agent1.ID, Content: "好,请提供一下您的订单号,我帮您查看", Type: "text", Seq: 2, SentAt: now},
{SessionID: sessions[0].ID, SenderType: "visitor", SenderID: &customers[0].ID, Content: "订单号 AB20260714001", Type: "text", Seq: 3, SentAt: now},
// Session 1: 张三 - 订单物流咨询 (urgent)
{SessionID: sessions[0].ID, SenderType: "visitor", Content: "好,我的订单怎么还没发货?已经3天了", Type: "text", Seq: 1, SentAt: now.Add(-10 * time.Minute)},
{SessionID: sessions[0].ID, SenderType: "agent", SenderID: &agent1.ID, Content: "您好,请提供一下您的订单号,我帮您查询", Type: "text", Seq: 2, SentAt: now.Add(-9 * time.Minute)},
{SessionID: sessions[0].ID, SenderType: "visitor", Content: "订单号 AB20260714001", Type: "text", Seq: 3, SentAt: now.Add(-8 * time.Minute)},
{SessionID: sessions[0].ID, SenderType: "agent", SenderID: &agent1.ID, Content: "稍等,正在为您查询物流信息...", Type: "text", Seq: 4, SentAt: now.Add(-7 * time.Minute)},
{SessionID: sessions[0].ID, SenderType: "agent", SenderID: &agent1.ID, Content: "您好,您的包裹已到达北京分拨中心,预计明天下午送达。给您带来不便敬请谅解", Type: "text", Seq: 5, SentAt: now.Add(-6 * time.Minute)},
// Session 2: 李四 - 退换货咨询 (active)
{SessionID: sessions[1].ID, SenderType: "visitor", Content: "你好,我买的商品有质量问题,想退货", Type: "text", Seq: 1, SentAt: now.Add(-5 * time.Minute)},
{SessionID: sessions[1].ID, SenderType: "agent", SenderID: &agent2.ID, Content: "非常抱歉给您带来不便。请拍照上传问题商品图片,我们核实后会为您办理退货", Type: "text", Seq: 2, SentAt: now.Add(-4 * time.Minute)},
{SessionID: sessions[1].ID, SenderType: "visitor", Content: "好的,我拍照上传", Type: "text", Seq: 3, SentAt: now.Add(-3 * time.Minute)},
// Session 3: 王五 - 等待分配 (waiting)
{SessionID: sessions[2].ID, SenderType: "visitor", Content: "您好,请问企业版是否有优惠?我们公司需要50个坐席", Type: "text", Seq: 1, SentAt: now.Add(-2 * time.Minute)},
// Session 4: 赵六科技 - 已结束
{SessionID: sessions[3].ID, SenderType: "visitor", Content: "产品使用手册在哪里下载?", Type: "text", Seq: 1, SentAt: now.Add(-2 * time.Hour)},
{SessionID: sessions[3].ID, SenderType: "agent", SenderID: &agent3.ID, Content: "您好,请在官网帮助中心→文档下载中获取,也可直接访问 docs.example.com", Type: "text", Seq: 2, SentAt: now.Add(-1 * time.Hour + 55*time.Minute)},
{SessionID: sessions[3].ID, SenderType: "visitor", Content: "找到了,谢谢!", Type: "text", Seq: 3, SentAt: now.Add(-1 * time.Hour + 50*time.Minute)},
// Session 5: 钱七 - 已结束
{SessionID: sessions[4].ID, SenderType: "visitor", Content: "退款什么时候到账?已经3天了", Type: "text", Seq: 1, SentAt: now.Add(-3 * time.Hour)},
{SessionID: sessions[4].ID, SenderType: "agent", SenderID: &agent1.ID, Content: "您好,退款一般在3-5个工作日到账,我帮您催一下财务", Type: "text", Seq: 2, SentAt: now.Add(-2 * time.Hour + 55*time.Minute)},
{SessionID: sessions[4].ID, SenderType: "agent", SenderID: &agent1.ID, Content: "已为您加急处理,预计明天到账,请注意查收", Type: "text", Seq: 3, SentAt: now.Add(-2 * time.Hour + 50*time.Minute)},
}
model.DB.Create(&messages)
+1
View File
@@ -41,6 +41,7 @@ func SetupRoutes(r *gin.Engine) {
sessions.POST("/:id/transfer", session.Transfer)
sessions.POST("/:id/end", session.End)
sessions.PUT("/:id/priority", session.UpdatePriority)
sessions.POST("/:id/messages", session.SendMessage)
// 客户管理
customers := authRequired.Group("/customers")
+47 -1
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"kefu-sys/server/internal/middleware"
@@ -12,6 +13,52 @@ type SessionHandler struct{}
func NewSessionHandler() *SessionHandler { return &SessionHandler{} }
type SendMessageReq struct {
Content string `json:"content" binding:"required"`
Type string `json:"type"`
}
func (h *SessionHandler) SendMessage(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
userID := middleware.GetUserID(c)
id := c.Param("id")
var req SendMessageReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
if req.Type == "" {
req.Type = "text"
}
var session model.Session
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&session).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"})
return
}
var maxSeq int
model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq)
msg := model.Message{
SessionID: session.ID,
SenderType: "agent",
SenderID: &userID,
Content: req.Content,
Type: req.Type,
Seq: maxSeq + 1,
SentAt: time.Now(),
}
if err := model.DB.Create(&msg).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
return
}
middleware.JSON(c, msg)
}
type CreateSessionReq struct {
ChannelID uint `json:"channel_id"`
CustomerID uint `json:"customer_id"`
@@ -170,7 +217,6 @@ func (h *SessionHandler) UpdatePriority(c *gin.Context) {
func parseID(s string) uint {
var id uint
// Simple atoi for uint
for _, c := range s {
if c >= '0' && c <= '9' {
id = id*10 + uint(c-'0')
+125 -91
View File
@@ -1,99 +1,118 @@
import { useState, useEffect, useRef } from 'react'
import { Input, Spin, message } from 'antd'
import { SearchOutlined, UserOutlined, StarFilled } from '@ant-design/icons'
import { Input, Spin, message as antMsg } from 'antd'
import { SearchOutlined, StarFilled } from '@ant-design/icons'
import { useAuth } from '@/stores/auth'
import { getSessions, getSession, type Session as SessionType } from '@/services/api'
interface SessionDetail {
id: number
customerName: string
phone: string
email: string
source: string
tags: string[]
status: string
conversationCount: number
satisfaction: number
note: string
messages: { sender: string; content: string; time: string }[]
}
import { getSessions, getSession, getCustomers, type Session as SessionType, type Customer } from '@/services/api'
const priorityColors: Record<string, string> = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' }
const priorityLabels: Record<string, string> = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' }
const statusLabels: Record<string, string> = { active: '进行中', waiting: '等待中', ended: '已结束' }
const Dashboard = () => {
const { user } = useAuth()
const [sessions, setSessions] = useState<SessionType[]>([])
const [customers, setCustomers] = useState<Record<number, Customer>>({})
const [loading, setLoading] = useState(true)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [detail, setDetail] = useState<SessionDetail | null>(null)
const [detail, setDetail] = useState<{ messages: { sender: string; content: string; time: string }[] } | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [messageInput, setMessageInput] = useState('')
const [sending, setSending] = useState(false)
const initialLoad = useRef(true)
const chatEndRef = useRef<HTMLDivElement>(null)
useEffect(() => {
loadSessions()
}, [])
useEffect(() => { loadAll() }, [])
const loadSessions = async () => {
const loadAll = async () => {
setLoading(true)
try {
const res = await getSessions()
console.log('Sessions loaded:', res)
setSessions(res.list)
if (res.list.length > 0 && initialLoad.current) {
setSelectedId(res.list[0].id)
const [sRes, cRes] = await Promise.all([
getSessions(),
getCustomers({ page: 1 }),
])
setSessions(sRes.list)
const map: Record<number, Customer> = {}
cRes.list.forEach(c => { map[c.id] = c })
setCustomers(map)
if (sRes.list.length > 0 && initialLoad.current) {
setSelectedId(sRes.list[0].id)
initialLoad.current = false
}
} catch (err) {
console.error('Failed to load sessions:', err)
message.error('加载会话失败,请重新登录')
console.error('加载失败:', err)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (selectedId) {
loadDetail(selectedId)
}
if (selectedId) loadDetail(selectedId)
}, [selectedId])
const loadDetail = async (id: number) => {
setDetailLoading(true)
try {
const res = await getSession(id)
const { session } = res.data
const msgs: any = res.data as any
setDetail({
id: session.id,
customerName: `客户${session.customer_id}`,
phone: '获取中...',
email: '',
source: '网页',
tags: session.priority === 'urgent' ? ['VIP'] : [],
status: session.status,
conversationCount: 0,
satisfaction: session.satisfaction_score || 0,
note: '',
messages: (res.data as any).messages?.map((m: any) => ({
sender: m.sender_type === 'agent' ? `客服(${m.sender_id})` : '客户',
messages: (msgs.messages || []).map((m: any) => ({
sender: m.sender_type === 'agent' ? '客服' : getCustomerName(id),
content: m.content,
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
})) || [],
})),
})
} catch {
message.error('加载会话详情失败')
antMsg.error('加载消息失败')
} finally {
setDetailLoading(false)
}
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 200)
}
const handleSend = () => {
if (!messageInput.trim()) return
setMessageInput('')
message.info('WebSocket 消息发送(待连接)')
const getCustomerName = (sessionId: number): string => {
const s = sessions.find(s => s.id === sessionId)
if (!s) return '访客'
const c = customers[s.customer_id]
return c ? c.name : `客户${s.customer_id}`
}
const handleSend = async () => {
if (!messageInput.trim() || !selectedId || sending) return
const text = messageInput.trim()
setMessageInput('')
setSending(true)
try {
const token = localStorage.getItem('auth_user')
const t = token ? JSON.parse(token).token : ''
const res = await fetch(`/api/sessions/${selectedId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
body: JSON.stringify({ content: text, type: 'text' }),
})
const json = await res.json()
if (json.code === 0) {
// 本地立即显示
setDetail(prev => prev ? {
messages: [...prev.messages, {
sender: '客服',
content: text,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
}],
} : prev)
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100)
} else {
antMsg.error(json.message || '发送失败')
}
} catch {
antMsg.error('发送失败')
} finally {
setSending(false)
}
}
const selected = sessions.find(s => s.id === selectedId)
const selectedCustomer = selected ? customers[selected.customer_id] : null
if (loading) {
return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
}
@@ -113,34 +132,43 @@ const Dashboard = () => {
{sessions.length === 0 ? (
<div className="flex items-center justify-center h-full text-neutral-400 text-sm"></div>
) : (
sessions.map(s => (
<div
key={s.id}
className={`px-3 py-2.5 cursor-pointer border-b border-neutral-50 hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
onClick={() => setSelectedId(s.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: priorityColors[s.priority] || '#2563eb' }} />
<span className="text-sm font-medium text-neutral-800">{s.customer_id}</span>
sessions.map(s => {
const c = customers[s.customer_id]
return (
<div key={s.id}
className={`px-3 py-2.5 cursor-pointer border-b border-neutral-50 hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
onClick={() => setSelectedId(s.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full" style={{ backgroundColor: priorityColors[s.priority] || '#2563eb' }} />
<span className="text-sm font-medium text-neutral-800">{c ? c.name : `客户${s.customer_id}`}</span>
</div>
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div className="flex items-center justify-between mt-1 pl-4">
<span className="text-xs text-neutral-400">{statusLabels[s.status] || s.status}</span>
{s.satisfaction_score && <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span>}
</div>
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
<p className="text-xs text-neutral-400 mt-1 truncate pl-4">
<TagBadge status={s.status} priority={s.priority} />
</p>
</div>
))
)
})
)}
</div>
</div>
{/* 中间:聊天区 */}
<div className="flex-1 flex flex-col min-w-0 bg-white">
{detail ? (
{selected && selectedCustomer ? (
<>
<div className="h-12 px-4 flex items-center justify-between border-b border-neutral-100 flex-shrink-0">
<span className="text-sm font-medium text-neutral-800">{detail.customerName}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-800">{selectedCustomer.name}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${selected.priority === 'urgent' ? 'text-red-600 bg-red-50' : 'text-blue-600 bg-blue-50'}`}>
{priorityLabels[selected.priority] || selected.priority}
</span>
<span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span>
</div>
<div className="flex gap-2">
<span className="text-xs text-neutral-400 cursor-pointer hover:text-neutral-600"></span>
<span className="text-xs text-neutral-400 cursor-pointer hover:text-red-500"></span>
@@ -149,18 +177,19 @@ const Dashboard = () => {
<div className="flex-1 overflow-auto p-4 flex flex-col gap-3">
{detailLoading ? (
<div className="flex-1 flex items-center justify-center"><Spin /></div>
) : detail.messages.length === 0 ? (
<div className="flex-1 flex items-center justify-center text-neutral-400 text-sm"></div>
) : (
) : detail && detail.messages.length > 0 ? (
detail.messages.map((msg, i) => (
<div key={i} className={`flex ${msg.sender.startsWith('客服') ? 'justify-start' : 'justify-end'}`}>
<div className={`max-w-[70%] rounded-lg px-3 py-2 text-sm ${msg.sender.startsWith('客服') ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
<div key={i} className={`flex ${msg.sender === '客服' ? 'justify-start' : 'justify-end'}`}>
<div className={`max-w-[65%] rounded-lg px-3 py-2 text-sm ${msg.sender === '客服' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
{msg.content}
<div className={`text-xs mt-1 ${msg.sender.startsWith('客服') ? 'text-neutral-400' : 'text-white/60'}`}>{msg.time}</div>
<div className={`text-xs mt-1 ${msg.sender === '客服' ? 'text-neutral-400' : 'text-white/60'}`}>{msg.time}</div>
</div>
</div>
))
) : (
<div className="flex-1 flex items-center justify-center text-neutral-400 text-sm"></div>
)}
<div ref={chatEndRef} />
</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-2">
@@ -169,9 +198,10 @@ const Dashboard = () => {
placeholder="输入消息... (Enter 发送)"
value={messageInput}
onChange={e => setMessageInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSend() }}
onKeyDown={e => { if (e.key === 'Enter' && !sending) handleSend() }}
disabled={sending}
/>
<span className="text-neutral-300 cursor-pointer hover:text-neutral-500">😊</span>
{sending && <Spin size="small" />}
</div>
</div>
</>
@@ -182,33 +212,43 @@ const Dashboard = () => {
{/* 右侧:客户信息面板 */}
<div className="w-[300px] flex-shrink-0 border-l border-neutral-200 bg-white overflow-auto">
{detail ? (
{selectedCustomer ? (
<div className="p-4 space-y-4">
<div className="flex items-center gap-3 pb-3 border-b border-neutral-100">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<UserOutlined className="text-blue-500" />
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-500 font-semibold">
{selectedCustomer.name[0]}
</div>
<div>
<div className="text-sm font-medium text-neutral-800">{detail.customerName}</div>
<div className="text-xs text-neutral-400">ID: {detail.id}</div>
<div className="text-sm font-medium text-neutral-800">{selectedCustomer.name}</div>
<div className="text-xs text-neutral-400">{selectedCustomer.source || '未知渠道'}</div>
</div>
</div>
<div>
<div className="text-xs text-neutral-400 mb-1.5"></div>
<div className="text-sm text-neutral-700 space-y-1">
{selectedCustomer.phone && <div>{selectedCustomer.phone}</div>}
{selectedCustomer.email && <div>{selectedCustomer.email}</div>}
</div>
</div>
<div>
<div className="text-xs text-neutral-400 mb-1.5"></div>
<div className="flex flex-wrap gap-1">
{detail.tags.map(t => <span key={t} className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-600">{t}</span>)}
{((): string[] => { try { return JSON.parse(selectedCustomer.tags) } catch { return [] } })().map((t: string) => (
<span key={t} className="text-xs px-2 py-0.5 rounded bg-blue-50 text-blue-600">{t}</span>
))}
</div>
</div>
<div>
<div className="text-xs text-neutral-400 mb-1.5"></div>
<div className="grid grid-cols-2 gap-2">
<div className="bg-neutral-50 rounded p-2 text-center">
<div className="text-lg font-semibold text-neutral-800">{detail.conversationCount}</div>
<div className="text-lg font-semibold text-neutral-800">{selectedCustomer.conversation_count}</div>
<div className="text-xs text-neutral-400"></div>
</div>
<div className="bg-neutral-50 rounded p-2 text-center">
<div className="text-lg font-semibold text-green-600 flex items-center justify-center gap-0.5">
{detail.satisfaction > 0 ? detail.satisfaction : '-'} {detail.satisfaction > 0 && <StarFilled className="text-xs" />}
{selected?.satisfaction_score || '-'}
{selected?.satisfaction_score ? <StarFilled className="text-xs" /> : null}
</div>
<div className="text-xs text-neutral-400"></div>
</div>
@@ -221,10 +261,4 @@ const Dashboard = () => {
)
}
function TagBadge({ status, priority }: { status: string; priority: string }) {
const color = status === 'ended' ? '#16a34a' : priorityColors[priority] || '#2563eb'
const text = status === 'ended' ? '已结束' : status === 'waiting' ? '等待中' : priorityLabels[priority] || status
return <span className="text-xs px-1.5 py-0.5 rounded" style={{ color, backgroundColor: `${color}15` }}>{text}</span>
}
export default Dashboard