diff --git a/server/cmd/seed/main.go b/server/cmd/seed/main.go index 60d583c..2240ef8 100644 --- a/server/cmd/seed/main.go +++ b/server/cmd/seed/main.go @@ -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) diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 9c191a0..a9d357c 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -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") diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index a35ac76..125de3a 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -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') diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index e9a26d4..481eed5 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -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 = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' } const priorityLabels: Record = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' } +const statusLabels: Record = { active: '进行中', waiting: '等待中', ended: '已结束' } const Dashboard = () => { const { user } = useAuth() const [sessions, setSessions] = useState([]) + const [customers, setCustomers] = useState>({}) const [loading, setLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) - const [detail, setDetail] = useState(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(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 = {} + 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
} @@ -113,34 +132,43 @@ const Dashboard = () => { {sessions.length === 0 ? (
暂无会话
) : ( - sessions.map(s => ( -
setSelectedId(s.id)} - > -
-
- - 客户{s.customer_id} + sessions.map(s => { + const c = customers[s.customer_id] + return ( +
setSelectedId(s.id)} + > +
+
+ + {c ? c.name : `客户${s.customer_id}`} +
+ {new Date(s.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} +
+
+ {statusLabels[s.status] || s.status} + {s.satisfaction_score && {'★'.repeat(s.satisfaction_score)}}
- {new Date(s.created_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
-

- -

-
- )) + ) + }) )}
{/* 中间:聊天区 */}
- {detail ? ( + {selected && selectedCustomer ? ( <>
- {detail.customerName} +
+ {selectedCustomer.name} + + {priorityLabels[selected.priority] || selected.priority} + + {statusLabels[selected.status]} +
转接 结束 @@ -149,18 +177,19 @@ const Dashboard = () => {
{detailLoading ? (
- ) : detail.messages.length === 0 ? ( -
暂无消息
- ) : ( + ) : detail && detail.messages.length > 0 ? ( detail.messages.map((msg, i) => ( -
-
+
+
{msg.content} -
{msg.time}
+
{msg.time}
)) + ) : ( +
暂无消息,开始对话吧
)} +
@@ -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} /> - 😊 + {sending && }
@@ -182,33 +212,43 @@ const Dashboard = () => { {/* 右侧:客户信息面板 */}
- {detail ? ( + {selectedCustomer ? (
-
- +
+ {selectedCustomer.name[0]}
-
{detail.customerName}
-
ID: {detail.id}
+
{selectedCustomer.name}
+
{selectedCustomer.source || '未知渠道'}
+
+
+
+
联系方式
+
+ {selectedCustomer.phone &&
{selectedCustomer.phone}
} + {selectedCustomer.email &&
{selectedCustomer.email}
}
标签
- {detail.tags.map(t => {t})} + {((): string[] => { try { return JSON.parse(selectedCustomer.tags) } catch { return [] } })().map((t: string) => ( + {t} + ))}
统计数据
-
{detail.conversationCount}
+
{selectedCustomer.conversation_count}
对话次数
- {detail.satisfaction > 0 ? detail.satisfaction : '-'} {detail.satisfaction > 0 && } + {selected?.satisfaction_score || '-'} + {selected?.satisfaction_score ? : null}
满意度
@@ -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 {text} -} - export default Dashboard