diff --git a/server/internal/handler/contact_extract.go b/server/internal/handler/contact_extract.go new file mode 100644 index 0000000..226d357 --- /dev/null +++ b/server/internal/handler/contact_extract.go @@ -0,0 +1,177 @@ +package handler + +import ( + "regexp" + "strings" + "unicode/utf8" + + "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/ws" +) + +var ( + reMobileCN = regexp.MustCompile(`(?i)(?:手机|电话|联系|号|tel|phone|mobile)?[::\s]*((?:\+?86[-\s]?)?1[3-9]\d{9})`) + reMobileStrict = regexp.MustCompile(`(?:^|[^\d])(1[3-9]\d{9})(?:[^\d]|$)`) + reEmail = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`) + // 允许「微信是 xxx / 微信:xxx / 微信号 xxx」等口语写法 + reWechat = regexp.MustCompile(`(?i)(?:微信|微信号|vx|v信|wx)[号]?(?:是|为|:|:|[\s ]+)[\s ]*([a-zA-Z][-_a-zA-Z0-9]{5,19})`) + reWechatID = regexp.MustCompile(`(?i)\b(wxid_[a-zA-Z0-9]{5,20})\b`) + reQQ = regexp.MustCompile(`(?i)(?:QQ|扣扣)[号]?(?:是|为|:|:|[\s ]+)[\s ]*([1-9]\d{4,11})`) +) + +type extractedContact struct { + Kind string + Value string +} + +// extractContactsFromText 从文本中提取联系方式(手机/微信/邮箱/QQ)。 +func extractContactsFromText(text string) []extractedContact { + text = strings.TrimSpace(text) + if text == "" { + return nil + } + // 限制扫描长度 + if utf8.RuneCountInString(text) > 2000 { + text = string([]rune(text)[:2000]) + } + + seen := map[string]bool{} + var out []extractedContact + add := func(kind, val string) { + val = strings.TrimSpace(val) + if val == "" { + return + } + // 规范化手机号 + if kind == "phone" { + val = regexp.MustCompile(`[^\d]`).ReplaceAllString(val, "") + if strings.HasPrefix(val, "86") && len(val) == 13 { + val = val[2:] + } + if len(val) != 11 || val[0] != '1' { + return + } + } + key := kind + ":" + strings.ToLower(val) + if seen[key] { + return + } + seen[key] = true + out = append(out, extractedContact{Kind: kind, Value: val}) + } + + for _, m := range reMobileCN.FindAllStringSubmatch(text, -1) { + if len(m) > 1 { + add("phone", m[1]) + } + } + for _, m := range reMobileStrict.FindAllStringSubmatch(text, -1) { + if len(m) > 1 { + add("phone", m[1]) + } + } + for _, m := range reEmail.FindAllString(text, -1) { + add("email", strings.ToLower(m)) + } + for _, m := range reWechat.FindAllStringSubmatch(text, -1) { + if len(m) > 1 { + add("wechat", m[1]) + } + } + for _, m := range reWechatID.FindAllStringSubmatch(text, -1) { + if len(m) > 1 { + add("wechat", m[1]) + } + } + for _, m := range reQQ.FindAllStringSubmatch(text, -1) { + if len(m) > 1 { + add("qq", m[1]) + } + } + return out +} + +// mergeCustomerContacts 将提取结果写入客户联系方式表(已存在则跳过,不覆盖)。 +// 同时:若 customers.phone/email 为空,用第一条补全(兼容旧列表搜索)。 +// 返回新插入的条数。 +func mergeCustomerContacts(tenantID, customerID uint, contacts []extractedContact, source string) (int, []model.CustomerContact) { + if customerID == 0 || len(contacts) == 0 { + return 0, nil + } + var customer model.Customer + if err := model.DB.Where("id = ? AND tenant_id = ?", customerID, tenantID).First(&customer).Error; err != nil { + return 0, nil + } + + added := 0 + var inserted []model.CustomerContact + updates := map[string]interface{}{} + + for _, c := range contacts { + var exists int64 + model.DB.Model(&model.CustomerContact{}). + Where("customer_id = ? AND kind = ? AND value = ?", customerID, c.Kind, c.Value). + Count(&exists) + if exists > 0 { + continue + } + row := model.CustomerContact{ + TenantID: tenantID, + CustomerID: customerID, + Kind: c.Kind, + Value: c.Value, + Source: source, + } + if err := model.DB.Create(&row).Error; err != nil { + continue + } + added++ + inserted = append(inserted, row) + + // 主字段仅在为空时填充,绝不覆盖已有值 + if c.Kind == "phone" && strings.TrimSpace(customer.Phone) == "" && updates["phone"] == nil { + updates["phone"] = c.Value + customer.Phone = c.Value + } + if c.Kind == "email" && strings.TrimSpace(customer.Email) == "" && updates["email"] == nil { + updates["email"] = c.Value + customer.Email = c.Value + } + } + if len(updates) > 0 { + model.DB.Model(&model.Customer{}).Where("id = ?", customerID).Updates(updates) + } + return added, inserted +} + +func listCustomerContacts(customerID uint) []model.CustomerContact { + var list []model.CustomerContact + model.DB.Where("customer_id = ?", customerID).Order("id asc").Find(&list) + return list +} + +// onVisitorDraftContacts 草稿文本提取联系方式并通知坐席刷新客户资料。 +func onVisitorDraftContacts(tenantID, customerID, sessionID uint, text string) { + contacts := extractContactsFromText(text) + if len(contacts) == 0 { + return + } + n, inserted := mergeCustomerContacts(tenantID, customerID, contacts, "draft") + if n == 0 { + return + } + // 推送客户资料更新(含全部联系方式) + var customer model.Customer + if err := model.DB.First(&customer, customerID).Error; err != nil { + return + } + all := listCustomerContacts(customerID) + payload, err := ws.NewEvent("customer_updated", sessionID, map[string]interface{}{ + "customer": customer, + "contacts": all, + "added_contacts": inserted, + }) + if err == nil { + ws.DefaultHub.BroadcastToTenantStaff(tenantID, payload) + } +} diff --git a/server/internal/handler/contact_extract_test.go b/server/internal/handler/contact_extract_test.go new file mode 100644 index 0000000..c90500d --- /dev/null +++ b/server/internal/handler/contact_extract_test.go @@ -0,0 +1,28 @@ +package handler + +import "testing" + +func TestExtractContactsFromText(t *testing.T) { + text := "你好,我微信是 abc_wx_01,手机 13800138000,邮箱 test@example.com QQ:123456" + got := extractContactsFromText(text) + kinds := map[string]string{} + for _, c := range got { + kinds[c.Kind] = c.Value + } + if kinds["phone"] != "13800138000" { + t.Fatalf("phone: %+v", got) + } + if kinds["wechat"] != "abc_wx_01" { + t.Fatalf("wechat: %+v", got) + } + if kinds["email"] != "test@example.com" { + t.Fatalf("email: %+v", got) + } + if kinds["qq"] != "123456" { + t.Fatalf("qq: %+v", got) + } + // 不完整不应误提 + if len(extractContactsFromText("微信 ab")) != 0 { + t.Fatal("partial wechat should not extract") + } +} diff --git a/server/internal/handler/customer.go b/server/internal/handler/customer.go index d7fc9e3..3d0e9c2 100644 --- a/server/internal/handler/customer.go +++ b/server/internal/handler/customer.go @@ -79,8 +79,13 @@ func (h *CustomerHandler) Get(c *gin.Context) { sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c)) } sessionQuery.Order("created_at desc").Limit(20).Find(&sessions) + contacts := listCustomerContacts(customer.ID) - middleware.JSON(c, gin.H{"customer": customer, "sessions": sessions}) + middleware.JSON(c, gin.H{ + "customer": customer, + "sessions": sessions, + "contacts": contacts, + }) } func (h *CustomerHandler) Create(c *gin.Context) { diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 50440db..df83da2 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -5,6 +5,7 @@ import ( "kefu-cloud/server/internal/config" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/storage" + "kefu-cloud/server/internal/ws" ) func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.StorageConfig) { @@ -19,11 +20,13 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S channel := NewChannelHandler() settings := NewSettingsHandler() staff := NewStaffHandler() - ws := NewWsHandler() + wsHandler := NewWsHandler() widget := NewWidgetHandler() upload := NewUploadHandler(store, storageCfg) SetImagePublicBase(storageCfg.PublicBase) + // 访客输入草稿 → 提取联系方式(由 ws 包回调,避免循环依赖) + ws.ProcessDraftContacts = onVisitorDraftContacts api := r.Group("/api") @@ -48,7 +51,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S authRequired.Use(middleware.AuthRequired()) { // WebSocket - authRequired.GET("/ws", ws.Connect) + authRequired.GET("/ws", wsHandler.Connect) // 上传 authRequired.POST("/uploads", upload.UploadImage) diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 30443f3..96377bb 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -432,8 +432,18 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) return } + // 发送后清空草稿 + _ = model.DB.Model(session).Updates(map[string]interface{}{ + "draft_text": "", + "draft_updated_at": time.Now(), + }).Error model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now()) + // 从已发送消息再提取一次联系方式 + if req.Type == "text" { + go onVisitorDraftContacts(session.TenantID, session.CustomerID, session.ID, content) + } + // 仍在排队时尝试自动分配(客服刚上线的场景) if session.Status == "waiting" && session.AgentID == nil { if _, err := tryAutoAssign(session); err == nil { @@ -449,6 +459,13 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) } } + // 通知坐席清空草稿预览 + if clearPayload, err := ws.NewEvent("input_draft", session.ID, map[string]interface{}{ + "from": "visitor", + "text": "", + }); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, clearPayload) + } c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg}) } diff --git a/server/internal/model/db.go b/server/internal/model/db.go index 13c2c03..e46ca16 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -34,6 +34,7 @@ func Migrate(db *gorm.DB) error { &Channel{}, &Customer{}, &CustomerTag{}, + &CustomerContact{}, &Session{}, &VisitorPageView{}, &Message{}, diff --git a/server/internal/model/models.go b/server/internal/model/models.go index bd21cb1..af44fd5 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -91,10 +91,13 @@ type Session struct { CurrentURL string `gorm:"size:1000" json:"current_url"` CurrentTitle string `gorm:"size:200" json:"current_title"` // LastSeenAt 访客最近活跃(心跳/换页),用于在线时长与在线状态 - LastSeenAt *time.Time `json:"last_seen_at"` - LastReadSeq int `gorm:"default:0" json:"last_read_seq"` - Status string `gorm:"size:20;default:waiting" json:"status"` - Priority string `gorm:"size:20;default:normal" json:"priority"` + LastSeenAt *time.Time `json:"last_seen_at"` + // DraftText 访客输入框未发送草稿(实时监控用,非聊天消息) + DraftText string `gorm:"type:text" json:"draft_text"` + DraftUpdatedAt *time.Time `json:"draft_updated_at"` + LastReadSeq int `gorm:"default:0" json:"last_read_seq"` + Status string `gorm:"size:20;default:waiting" json:"status"` + Priority string `gorm:"size:20;default:normal" json:"priority"` SatisfactionScore *int `json:"satisfaction_score"` SatisfactionText string `gorm:"size:500" json:"satisfaction_text"` EndReason string `gorm:"size:50" json:"end_reason"` @@ -103,6 +106,17 @@ type Session struct { UpdatedAt time.Time `json:"updated_at"` } +// CustomerContact 客户联系方式(多条不覆盖,各自独立记录)。 +type CustomerContact struct { + ID uint `gorm:"primaryKey" json:"id"` + TenantID uint `gorm:"index;not null" json:"tenant_id"` + CustomerID uint `gorm:"uniqueIndex:idx_cust_contact;not null" json:"customer_id"` + Kind string `gorm:"size:20;uniqueIndex:idx_cust_contact;not null" json:"kind"` // phone|wechat|email|qq + Value string `gorm:"size:100;uniqueIndex:idx_cust_contact;not null" json:"value"` + Source string `gorm:"size:30" json:"source"` // draft|message|leave|agent + CreatedAt time.Time `json:"created_at"` +} + // VisitorPageView 访客在宿主站的页面浏览记录(按会话)。 type VisitorPageView struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/server/internal/ws/ws.go b/server/internal/ws/ws.go index 5137374..747de94 100644 --- a/server/internal/ws/ws.go +++ b/server/internal/ws/ws.go @@ -4,13 +4,25 @@ import ( "encoding/json" "log" "net/http" + "strings" "sync" "time" + "unicode/utf8" "github.com/gorilla/websocket" "kefu-cloud/server/internal/model" ) +// ProcessDraftContacts 由 handler 包注入,避免 ws ↔ handler 循环依赖。 +// tenantID, customerID, sessionID, draftText +var ProcessDraftContacts func(tenantID, customerID, sessionID uint, text string) + +func processDraftContacts(tenantID, customerID, sessionID uint, text string) { + if ProcessDraftContacts != nil { + ProcessDraftContacts(tenantID, customerID, sessionID, text) + } +} + var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, Subprotocols: []string{"kefu-v1", "kefu-visitor-v1"}, @@ -36,6 +48,8 @@ type Event struct { type ClientEvent struct { Type string `json:"type"` SessionID uint `json:"session_id"` + // Text 访客输入框草稿(type=input_draft 或 typing 附带) + Text string `json:"text,omitempty"` } type Hub struct { @@ -166,8 +180,14 @@ func (h *Hub) BroadcastToSessionStaff(tenantID uint, agentID *uint, message []by } } +const maxDraftTextRunes = 500 + func handleClientEvent(client *Client, event ClientEvent) { - if event.Type != "typing" || event.SessionID == 0 { + if event.SessionID == 0 { + return + } + // typing:仅指示;input_draft:带正文草稿 + if event.Type != "typing" && event.Type != "input_draft" { return } var session model.Session @@ -175,7 +195,7 @@ func handleClientEvent(client *Client, event ClientEvent) { return } - // 访客输入中 → 通知可接待的客服 + // 访客输入中 / 草稿 → 通知坐席 if client.Kind == "visitor" { if client.SessionID == nil || *client.SessionID != event.SessionID { return @@ -183,6 +203,34 @@ func handleClientEvent(client *Client, event ClientEvent) { if session.Status == "ended" || session.Status == "archived" { return } + + if event.Type == "input_draft" { + text := strings.TrimSpace(event.Text) + if utf8.RuneCountInString(text) > maxDraftTextRunes { + text = string([]rune(text)[:maxDraftTextRunes]) + } + now := time.Now() + _ = model.DB.Model(&session).Updates(map[string]interface{}{ + "draft_text": text, + "draft_updated_at": now, + }).Error + + // 异步提取联系方式(不阻塞 WS) + go processDraftContacts(session.TenantID, session.CustomerID, session.ID, text) + + payload, err := NewEvent("input_draft", session.ID, map[string]interface{}{ + "from": "visitor", + "text": text, + }) + if err != nil { + return + } + // 排队中也让租户坐席能看到 + DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + return + } + + // 兼容旧 typing(无正文) payload, err := NewEvent("typing", session.ID, map[string]string{"from": "visitor"}) if err != nil { return @@ -195,8 +243,8 @@ func handleClientEvent(client *Client, event ClientEvent) { return } - // 客服输入中 → 通知访客 - if client.Kind != "agent" { + // 客服输入中 → 通知访客(不传草稿内容) + if client.Kind != "agent" || event.Type != "typing" { return } if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) { @@ -217,7 +265,7 @@ func HandleWebSocket(client *Client) { client.Conn.Close() }() - client.Conn.SetReadLimit(1024) + client.Conn.SetReadLimit(4096) // 允许访客草稿正文 client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) client.Conn.SetPongHandler(func(string) error { client.Conn.SetReadDeadline(time.Now().Add(60 * time.Second)) diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index d9b324b..88fd597 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -10,10 +10,10 @@ import { ChatImage } from '@/components/common/ImagePreview' import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody' import { useAuth } from '@/stores/auth' import { - addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries, + addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply, - type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply, + type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply, type Session, type SessionEvent, type VisitorPageView, } from '@/services/api' @@ -182,6 +182,9 @@ const Dashboard = () => { const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all') const [filterOpen, setFilterOpen] = useState(false) const [visitorTyping, setVisitorTyping] = useState(false) + /** 访客输入框未发送草稿(实时) */ + const [visitorDraft, setVisitorDraft] = useState('') + const [customerContacts, setCustomerContacts] = useState([]) /** 驱动在线读秒每秒刷新 */ const [clockTick, setClockTick] = useState(0) const initialLoad = useRef(true) @@ -282,6 +285,10 @@ const Dashboard = () => { } : session, )) + // 仅在主动打开会话时恢复草稿;静默轮询不覆盖 WS 实时值 + if (!opts?.silent && selectedIdRef.current === id) { + setVisitorDraft(data.session.draft_text || '') + } } else if (markRead) { setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session)) } @@ -452,9 +459,39 @@ const Dashboard = () => { return } + if (payload.type === 'input_draft' && sameSession) { + const text = typeof (payload.data as { text?: string })?.text === 'string' + ? String((payload.data as { text?: string }).text) + : '' + setVisitorDraft(text) + setVisitorTyping(text.length > 0) + if (visitorTypingTimer.current) clearTimeout(visitorTypingTimer.current) + if (text) { + visitorTypingTimer.current = window.setTimeout(() => setVisitorTyping(false), 2500) + } + return + } + + if (payload.type === 'customer_updated' && sameSession && payload.data) { + const d = payload.data as { + customer?: Customer + contacts?: CustomerContact[] + } + if (d.customer) { + setCustomers(prev => ({ ...prev, [d.customer!.id]: { ...prev[d.customer!.id], ...d.customer! } })) + } + if (Array.isArray(d.contacts)) { + setCustomerContacts(d.contacts) + } + return + } + if (payload.type === 'message') { if (sameSession) { setVisitorTyping(false) + // 访客发出消息后草稿应清空(后端也会推 input_draft 空串,这里兜底) + const sender = (payload.data as { sender_type?: string })?.sender_type + if (sender === 'visitor') setVisitorDraft('') const ok = appendPushedMessage(sid, payload.data || {}) if (!ok) void syncAfterSeqRef.current(sid, { markRead: true }) } @@ -534,6 +571,8 @@ const Dashboard = () => { useEffect(() => { setVisitorTyping(false) + setVisitorDraft('') + setCustomerContacts([]) }, [selectedId]) useEffect(() => { @@ -542,6 +581,12 @@ const Dashboard = () => { } }, [visitorTyping]) + useEffect(() => { + if (visitorDraft.trim()) { + setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40) + } + }, [visitorDraft]) + useEffect(() => { if (selectedId) loadDetail(selectedId) }, [selectedId, loadDetail]) @@ -647,6 +692,28 @@ const Dashboard = () => { }).catch(() => setCustomerHistory([])) }, [selectedCustomer?.id, selectedId]) + // 拉取客户详情中的多条联系方式(输入识别沉淀) + useEffect(() => { + if (!selectedCustomer?.id) return + let cancelled = false + getCustomer(selectedCustomer.id) + .then(res => { + if (cancelled) return + const data = res.data + if (data?.customer) { + setCustomers(prev => ({ + ...prev, + [data.customer.id]: { ...prev[data.customer.id], ...data.customer }, + })) + } + if (Array.isArray(data?.contacts)) { + setCustomerContacts(data.contacts) + } + }) + .catch(() => { /* 侧栏增强信息失败不影响会话 */ }) + return () => { cancelled = true } + }, [selectedCustomer?.id, selectedId]) + const filterActive = statusFilter !== 'all' || priorityFilter !== 'all' const filteredSessions = sessions .filter(session => { @@ -1229,6 +1296,31 @@ const Dashboard = () => {
+ {/* 访客实时输入草稿:展示在工具栏/输入框上方 */} + {(visitorDraft.trim() || visitorTyping) && ( +
+
+ + 对方正在输入 + {visitorTyping && ( + + + + + + )} + {visitorDraft.trim() ? ':' : ''} + + {visitorDraft.trim() ? ( + + {visitorDraft.trim()} + + ) : ( + + )} +
+
+ )} {selected.status !== 'active' || !canOperate ? (
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'} @@ -1411,6 +1503,33 @@ const Dashboard = () => { 邮箱 {selectedCustomer.email || '—'}
+ {(() => { + const kindLabel = (k: string) => + k === 'phone' ? '手机' : k === 'wechat' ? '微信' : k === 'email' ? '邮箱' : k === 'qq' ? 'QQ' : k + // 主字段已展示的手机/邮箱不再重复;微信/QQ 等一律展示 + const extra = customerContacts.filter(c => { + if (c.kind === 'phone' && selectedCustomer.phone && c.value === selectedCustomer.phone) return false + if (c.kind === 'email' && selectedCustomer.email && c.value === selectedCustomer.email) return false + return true + }) + if (extra.length === 0) return null + return ( +
+ 更多 +
+ {extra.map(c => ( + + {kindLabel(c.kind)} + {c.value} + {(c.source === 'draft' || c.source === 'message') && ( + 自动识别 + )} + + ))} +
+
+ ) + })()}
来源 {selectedCustomer.source || '—'} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index bc7a1e8..5bb0da9 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -18,6 +18,9 @@ export interface Session { current_url?: string current_title?: string last_seen_at?: string | null + /** 访客输入框未发送草稿 */ + draft_text?: string + draft_updated_at?: string | null created_at: string; ended_at: string | null /** 列表接口补全字段 */ message_count?: number @@ -27,6 +30,16 @@ export interface Session { channel_type?: string } +export interface CustomerContact { + id: number + tenant_id?: number + customer_id: number + kind: string // phone | wechat | email | qq + value: string + source?: string + created_at?: string +} + export interface VisitorPageView { id: number session_id: number @@ -50,6 +63,7 @@ export interface AvailableAgent { id: number; nickname: string; status: string } export interface Customer { id: number; tenant_id: number; name: string; phone: string; email: string; tags: string source: string; status: string; conversation_count: number; last_contact_at: string + contacts?: CustomerContact[] } export interface KnowledgeCategory { @@ -303,7 +317,8 @@ export const exportSessionsCSV = (params?: { export const exportStatisticsCSV = () => downloadFile('/statistics/export', `statistics_${Date.now()}.csv`) -export const getCustomer = (id: number) => get<{ customer: Customer; sessions: Session[] }>(`/customers/${id}`) +export const getCustomer = (id: number) => + get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`) export const createCustomer = (data: Partial) => post('/customers', data) export const updateCustomer = (id: number, data: Partial) => put(`/customers/${id}`, data) export const deleteCustomer = (id: number) => del(`/customers/${id}`) diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 3bd417a..05ccba1 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -153,7 +153,7 @@ const VisitorChat = ({ const insertEmoji = (emoji: string) => { const { next, cursor } = insertAtCursor(textInputRef.current, input, emoji) setInput(next) - if (agentsOnline) emitTyping() + emitInputDraft(next) requestAnimationFrame(() => { const el = textInputRef.current if (!el) return @@ -688,7 +688,23 @@ const VisitorChat = ({ } } + /** 向坐席同步输入框草稿(节流);清空时立即发送 */ + const emitInputDraft = useCallback((text: string, force = false) => { + if (!sessionId || sessionEnded) return + if (socketRef.current?.readyState !== WebSocket.OPEN) return + const now = Date.now() + if (!force && now - lastTypingAt.current < 400) return + lastTypingAt.current = now + const clipped = text.length > 500 ? text.slice(0, 500) : text + socketRef.current.send(JSON.stringify({ + type: 'input_draft', + session_id: sessionId, + text: clipped, + })) + }, [sessionId, sessionEnded]) + const emitTyping = () => { + // 兼容:无正文的旧 typing;正文走 emitInputDraft if (!sessionId || sessionEnded || !agentsOnline) return const now = Date.now() if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return @@ -734,6 +750,7 @@ const VisitorChat = ({ } const content = text.trim() setInput('') + emitInputDraft('', true) setSendError('') setSending(true) @@ -1161,7 +1178,11 @@ const VisitorChat = ({ : '描述您的问题(留言)' } value={input} - onChange={e => { setInput(e.target.value); if (agentsOnline) emitTyping() }} + onChange={e => { + const v = e.target.value + setInput(v) + emitInputDraft(v, v === '') + }} onPaste={e => { if (!agentsOnline) return const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))