diff --git a/controller/offline_message.go b/controller/offline_message.go new file mode 100644 index 0000000..674cf3c --- /dev/null +++ b/controller/offline_message.go @@ -0,0 +1,490 @@ +package controller + +import ( + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "goflylivechat/models" + "goflylivechat/tools" + "goflylivechat/ws" + "log" + "strconv" + "time" +) + +func SubmitOfflineMessage(c *gin.Context) { + kefuId := c.PostForm("kefu_id") + visitorId := c.PostForm("visitor_id") + visitorName := c.PostForm("visitor_name") + visitorEmail := c.PostForm("visitor_email") + visitorPhone := c.PostForm("visitor_phone") + subject := c.PostForm("subject") + content := c.PostForm("content") + refer := c.PostForm("refer") + extra := c.PostForm("extra") + + if kefuId == "" || content == "" { + c.JSON(200, gin.H{ + "code": 400, + "msg": "客服ID和留言内容不能为空", + }) + return + } + + if visitorName == "" { + visitorName = "匿名访客" + } + + if !tools.LimitFreqSingle("offline_message:"+c.ClientIP(), 1, 60) { + c.JSON(200, gin.H{ + "code": 400, + "msg": "提交频率过快,请稍后再试", + }) + return + } + + kefuInfo := models.FindUser(kefuId) + if kefuInfo.ID == 0 { + c.JSON(200, gin.H{ + "code": 400, + "msg": "客服不存在", + }) + return + } + + if visitorId == "" { + visitorId = tools.Uuid() + } + + sourceIp := c.ClientIP() + + msgId := models.CreateOfflineMessage( + kefuId, + visitorId, + visitorName, + visitorEmail, + visitorPhone, + subject, + content, + sourceIp, + refer, + extra, + ) + + if visitorEmail != "" { + go SendOfflineMessageNotice(kefuInfo.Nickname, visitorName, content) + } + + c.JSON(200, gin.H{ + "code": 200, + "msg": "留言提交成功,客服将尽快回复您", + "result": gin.H{ + "message_id": msgId, + }, + }) +} + +func GetOfflineMessages(c *gin.Context) { + kefuName, _ := c.Get("kefu_name") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pagesize", "10")) + status := c.DefaultQuery("status", "") + + if pageSize > 50 { + pageSize = 50 + } + + var query interface{} + var args []interface{} + + if status != "" { + if !models.IsValidOfflineMessageStatus(status) { + c.JSON(200, gin.H{ + "code": 400, + "msg": "无效的状态值", + }) + return + } + query = "offline_message.kefu_id = ? AND offline_message.status = ?" + args = append(args, kefuName.(string), status) + } else { + query = "offline_message.kefu_id = ?" + args = append(args, kefuName.(string)) + } + + count := models.CountOfflineMessages(query, args...) + list := models.FindOfflineMessagesByPage(uint(page), uint(pageSize), query, args...) + + c.JSON(200, gin.H{ + "code": 200, + "msg": "ok", + "result": gin.H{ + "count": count, + "page": page, + "pagesize": pageSize, + "list": list, + }, + }) +} + +func GetOfflineMessageDetail(c *gin.Context) { + kefuName, _ := c.Get("kefu_name") + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(200, gin.H{ + "code": 400, + "msg": "无效的留言ID", + }) + return + } + + msg := models.FindOfflineMessageDetailById(uint(id)) + if msg.ID == 0 { + c.JSON(200, gin.H{ + "code": 404, + "msg": "留言不存在", + }) + return + } + + if msg.KefuId != kefuName.(string) { + c.JSON(200, gin.H{ + "code": 403, + "msg": "无权访问此留言", + }) + return + } + + if msg.Status == models.OfflineMessageStatusUnread { + models.MarkOfflineMessageAsRead(uint(id)) + } + + c.JSON(200, gin.H{ + "code": 200, + "msg": "ok", + "result": gin.H{ + "id": msg.ID, + "kefu_id": msg.KefuId, + "visitor_id": msg.VisitorId, + "visitor_name": msg.VisitorName, + "visitor_email": msg.VisitorEmail, + "visitor_phone": msg.VisitorPhone, + "subject": msg.Subject, + "content": msg.Content, + "status": msg.Status, + "status_text": models.GetOfflineMessageStatusText(msg.Status), + "reply_content": msg.ReplyContent, + "reply_time": msg.ReplyTime, + "replied_by": msg.RepliedBy, + "source_ip": msg.SourceIp, + "refer": msg.Refer, + "visitor_avator": msg.VisitorAvator, + "kefu_name": msg.KefuName, + "kefu_avator": msg.KefuAvator, + "create_time": msg.CreateTime, + }, + }) +} + +func ReplyOfflineMessage(c *gin.Context) { + kefuName, _ := c.Get("kefu_name") + idStr := c.PostForm("id") + replyContent := c.PostForm("reply_content") + + if idStr == "" || replyContent == "" { + c.JSON(200, gin.H{ + "code": 400, + "msg": "留言ID和回复内容不能为空", + }) + return + } + + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(200, gin.H{ + "code": 400, + "msg": "无效的留言ID", + }) + return + } + + msg := models.FindOfflineMessageById(uint(id)) + if msg.ID == 0 { + c.JSON(200, gin.H{ + "code": 404, + "msg": "留言不存在", + }) + return + } + + if msg.KefuId != kefuName.(string) { + c.JSON(200, gin.H{ + "code": 403, + "msg": "无权回复此留言", + }) + return + } + + models.UpdateOfflineMessageReply(uint(id), replyContent, kefuName.(string)) + + if msg.VisitorEmail != "" { + go SendOfflineReplyNotice(msg.VisitorName, msg.VisitorEmail, replyContent) + } + + c.JSON(200, gin.H{ + "code": 200, + "msg": "回复成功", + }) +} + +func UpdateOfflineMessageStatus(c *gin.Context) { + kefuName, _ := c.Get("kefu_name") + idStr := c.Param("id") + status := c.PostForm("status") + + if idStr == "" || status == "" { + c.JSON(200, gin.H{ + "code": 400, + "msg": "留言ID和状态不能为空", + }) + return + } + + if !models.IsValidOfflineMessageStatus(status) { + c.JSON(200, gin.H{ + "code": 400, + "msg": "无效的状态值", + }) + return + } + + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(200, gin.H{ + "code": 400, + "msg": "无效的留言ID", + }) + return + } + + msg := models.FindOfflineMessageById(uint(id)) + if msg.ID == 0 { + c.JSON(200, gin.H{ + "code": 404, + "msg": "留言不存在", + }) + return + } + + if msg.KefuId != kefuName.(string) { + c.JSON(200, gin.H{ + "code": 403, + "msg": "无权操作此留言", + }) + return + } + + models.UpdateOfflineMessageStatus(uint(id), models.OfflineMessageStatus(status)) + + c.JSON(200, gin.H{ + "code": 200, + "msg": "状态更新成功", + }) +} + +func DeleteOfflineMessage(c *gin.Context) { + kefuName, _ := c.Get("kefu_name") + idStr := c.Param("id") + + if idStr == "" { + c.JSON(200, gin.H{ + "code": 400, + "msg": "留言ID不能为空", + }) + return + } + + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(200, gin.H{ + "code": 400, + "msg": "无效的留言ID", + }) + return + } + + msg := models.FindOfflineMessageById(uint(id)) + if msg.ID == 0 { + c.JSON(200, gin.H{ + "code": 404, + "msg": "留言不存在", + }) + return + } + + if msg.KefuId != kefuName.(string) { + c.JSON(200, gin.H{ + "code": 403, + "msg": "无权删除此留言", + }) + return + } + + models.DeleteOfflineMessage(uint(id)) + + c.JSON(200, gin.H{ + "code": 200, + "msg": "删除成功", + }) +} + +func GetOfflineMessageStats(c *gin.Context) { + kefuName, _ := c.Get("kefu_name") + stats := models.GetOfflineMessageStatsByKefuId(kefuName.(string)) + + c.JSON(200, gin.H{ + "code": 200, + "msg": "ok", + "result": gin.H{ + "unread": stats["unread"], + "read": stats["read"], + "replied": stats["replied"], + "total": stats["total"], + }, + }) +} + +func BatchMarkOfflineMessagesAsRead(c *gin.Context) { + kefuName, _ := c.Get("kefu_name") + idsJson := c.PostForm("ids") + + now := time.Now() + var updateCount int64 + + if idsJson == "" { + result := models.DB.Model(&models.OfflineMessage{}). + Where("kefu_id = ? AND status = ?", kefuName.(string), models.OfflineMessageStatusUnread). + Updates(map[string]interface{}{ + "status": models.OfflineMessageStatusRead, + "updated_at": now, + }) + updateCount = result.RowsAffected + } else { + var ids []uint + err := json.Unmarshal([]byte(idsJson), &ids) + if err != nil { + c.JSON(200, gin.H{ + "code": 400, + "msg": "无效的ID格式", + }) + return + } + + if len(ids) == 0 { + c.JSON(200, gin.H{ + "code": 400, + "msg": "没有有效的留言ID", + }) + return + } + + for _, id := range ids { + msg := models.FindOfflineMessageById(id) + if msg.ID == 0 || msg.KefuId != kefuName.(string) { + c.JSON(200, gin.H{ + "code": 403, + "msg": fmt.Sprintf("无权操作留言ID: %d", id), + }) + return + } + } + + err = models.BatchMarkAsRead(ids) + if err != nil { + c.JSON(200, gin.H{ + "code": 500, + "msg": "批量标记失败", + }) + return + } + updateCount = int64(len(ids)) + } + + c.JSON(200, gin.H{ + "code": 200, + "msg": fmt.Sprintf("已标记 %d 条消息为已读", updateCount), + "result": gin.H{ + "count": updateCount, + }, + }) +} + +func CheckKefuOnline(c *gin.Context) { + kefuId := c.Query("kefu_id") + if kefuId == "" { + c.JSON(200, gin.H{ + "code": 400, + "msg": "客服ID不能为空", + }) + return + } + + kefuInfo := models.FindUser(kefuId) + if kefuInfo.ID == 0 { + c.JSON(200, gin.H{ + "code": 404, + "msg": "客服不存在", + }) + return + } + + isOnline := false + for name := range ws.KefuList { + if name == kefuId { + isOnline = true + break + } + } + + c.JSON(200, gin.H{ + "code": 200, + "msg": "ok", + "result": gin.H{ + "kefu_id": kefuId, + "nickname": kefuInfo.Nickname, + "avator": kefuInfo.Avator, + "is_online": isOnline, + }, + }) +} + +func SendOfflineMessageNotice(kefuName, visitorName, content string) { + smtp := models.FindConfig("NoticeEmailSmtp") + email := models.FindConfig("NoticeEmailAddress") + password := models.FindConfig("NoticeEmailPassword") + if smtp == "" || email == "" || password == "" { + return + } + subject := fmt.Sprintf("[离线留言] %s 发来新消息", visitorName) + body := fmt.Sprintf("访客: %s\n留言内容: %s", visitorName, content) + err := tools.SendSmtp(smtp, email, password, []string{email}, subject, body) + if err != nil { + log.Println("SendOfflineMessageNotice error:", err) + } +} + +func SendOfflineReplyNotice(visitorName, visitorEmail, replyContent string) { + smtp := models.FindConfig("NoticeEmailSmtp") + email := models.FindConfig("NoticeEmailAddress") + password := models.FindConfig("NoticeEmailPassword") + if smtp == "" || email == "" || password == "" { + return + } + subject := "[留言回复] 客服已回复您的留言" + body := fmt.Sprintf("尊敬的 %s:\n\n客服已回复您的留言:\n%s", visitorName, replyContent) + err := tools.SendSmtp(smtp, email, password, []string{visitorEmail}, subject, body) + if err != nil { + log.Println("SendOfflineReplyNotice error:", err) + } +} diff --git a/goflylivechat.exe b/goflylivechat.exe new file mode 100644 index 0000000..9b335fe Binary files /dev/null and b/goflylivechat.exe differ diff --git a/import.sql b/import.sql index 6d4cb54..7545b94 100644 --- a/import.sql +++ b/import.sql @@ -110,4 +110,31 @@ CREATE TABLE `reply_item` ( PRIMARY KEY (`id`), KEY `user_id` (`user_id`), KEY `group_id` (`group_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8; \ No newline at end of file +) ENGINE=InnoDB DEFAULT CHARSET=utf8; + +DROP TABLE IF EXISTS `offline_message`; +CREATE TABLE `offline_message` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `kefu_id` varchar(100) NOT NULL DEFAULT '' COMMENT '客服ID', + `visitor_id` varchar(100) NOT NULL DEFAULT '' COMMENT '访客ID', + `visitor_name` varchar(100) NOT NULL DEFAULT '' COMMENT '访客姓名', + `visitor_email` varchar(100) NOT NULL DEFAULT '' COMMENT '访客邮箱', + `visitor_phone` varchar(50) NOT NULL DEFAULT '' COMMENT '访客电话', + `subject` varchar(255) NOT NULL DEFAULT '' COMMENT '留言主题', + `content` text NOT NULL COMMENT '留言内容', + `status` enum('unread','read','replied') NOT NULL DEFAULT 'unread' COMMENT '状态:未读/已读/已回复', + `reply_content` text COMMENT '回复内容', + `reply_time` timestamp NULL DEFAULT NULL COMMENT '回复时间', + `replied_by` varchar(100) NOT NULL DEFAULT '' COMMENT '回复人', + `source_ip` varchar(50) NOT NULL DEFAULT '' COMMENT '来源IP', + `refer` varchar(500) NOT NULL DEFAULT '' COMMENT '来源页面', + `extra` varchar(2048) NOT NULL DEFAULT '' COMMENT '额外信息', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NULL DEFAULT NULL, + `deleted_at` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `kefu_id` (`kefu_id`), + KEY `visitor_id` (`visitor_id`), + KEY `status` (`status`), + KEY `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='离线留言表'; \ No newline at end of file diff --git a/models/offline_message.go b/models/offline_message.go new file mode 100644 index 0000000..9fcb0f6 --- /dev/null +++ b/models/offline_message.go @@ -0,0 +1,232 @@ +package models + +import ( + "time" +) + +type OfflineMessageStatus string + +const ( + OfflineMessageStatusUnread OfflineMessageStatus = "unread" + OfflineMessageStatusRead OfflineMessageStatus = "read" + OfflineMessageStatusReplied OfflineMessageStatus = "replied" +) + +type OfflineMessage struct { + Model + KefuId string `json:"kefu_id"` + VisitorId string `json:"visitor_id"` + VisitorName string `json:"visitor_name"` + VisitorEmail string `json:"visitor_email"` + VisitorPhone string `json:"visitor_phone"` + Subject string `json:"subject"` + Content string `json:"content"` + Status OfflineMessageStatus `json:"status"` + ReplyContent string `json:"reply_content"` + ReplyTime *time.Time `json:"reply_time"` + RepliedBy string `json:"replied_by"` + SourceIp string `json:"source_ip"` + Refer string `json:"refer"` + Extra string `json:"extra"` +} + +type OfflineMessageDetail struct { + OfflineMessage + VisitorAvator string `json:"visitor_avator"` + KefuName string `json:"kefu_name"` + KefuAvator string `json:"kefu_avator"` + CreateTime string `json:"create_time"` +} + +func CreateOfflineMessage(kefuId, visitorId, visitorName, visitorEmail, visitorPhone, subject, content, sourceIp, refer, extra string) uint { + msg := &OfflineMessage{ + KefuId: kefuId, + VisitorId: visitorId, + VisitorName: visitorName, + VisitorEmail: visitorEmail, + VisitorPhone: visitorPhone, + Subject: subject, + Content: content, + Status: OfflineMessageStatusUnread, + SourceIp: sourceIp, + Refer: refer, + Extra: extra, + } + msg.UpdatedAt = time.Now() + DB.Create(msg) + return msg.ID +} + +func FindOfflineMessageById(id uint) OfflineMessage { + var msg OfflineMessage + DB.Where("id = ?", id).First(&msg) + return msg +} + +func FindOfflineMessageDetailById(id uint) OfflineMessageDetail { + var msg OfflineMessageDetail + DB.Table("offline_message"). + Select("offline_message.*, visitor.avator as visitor_avator, user.nickname as kefu_name, user.avator as kefu_avator"). + Joins("left join user on offline_message.kefu_id = user.name"). + Joins("left join visitor on offline_message.visitor_id = visitor.visitor_id"). + Where("offline_message.id = ?", id). + First(&msg) + msg.CreateTime = msg.CreatedAt.Format("2006-01-02 15:04:05") + return msg +} + +func UpdateOfflineMessageStatus(id uint, status OfflineMessageStatus) { + msg := &OfflineMessage{ + Status: status, + } + msg.UpdatedAt = time.Now() + DB.Model(&OfflineMessage{}).Where("id = ?", id).Update(msg) +} + +func UpdateOfflineMessageReply(id uint, replyContent string, repliedBy string) { + now := time.Now() + msg := &OfflineMessage{ + Status: OfflineMessageStatusReplied, + ReplyContent: replyContent, + ReplyTime: &now, + RepliedBy: repliedBy, + } + msg.UpdatedAt = now + DB.Model(&OfflineMessage{}).Where("id = ?", id).Updates(msg) +} + +func DeleteOfflineMessage(id uint) { + DB.Where("id = ?", id).Delete(OfflineMessage{}) +} + +func CountOfflineMessagesByKefuId(kefuId string, status OfflineMessageStatus) uint { + var count uint + query := DB.Model(&OfflineMessage{}).Where("kefu_id = ?", kefuId) + if status != "" { + query = query.Where("status = ?", status) + } + query.Count(&count) + return count +} + +func CountOfflineMessages(query interface{}, args ...interface{}) uint { + var count uint + DB.Model(&OfflineMessage{}).Where(query, args...).Count(&count) + return count +} + +func FindOfflineMessagesByPage(page uint, pagesize uint, query interface{}, args ...interface{}) []*OfflineMessageDetail { + offset := (page - 1) * pagesize + if offset < 0 { + offset = 0 + } + var messages []*OfflineMessageDetail + baseQuery := DB.Table("offline_message"). + Select("offline_message.*, visitor.avator as visitor_avator, user.nickname as kefu_name, user.avator as kefu_avator"). + Joins("left join user on offline_message.kefu_id = user.name"). + Joins("left join visitor on offline_message.visitor_id = visitor.visitor_id") + + if query != nil { + baseQuery = baseQuery.Where(query, args...) + } + + baseQuery.Offset(offset).Limit(pagesize).Order("offline_message.created_at desc").Find(&messages) + + for _, msg := range messages { + msg.CreateTime = msg.CreatedAt.Format("2006-01-02 15:04:05") + } + return messages +} + +func FindOfflineMessagesByStatus(kefuId string, status OfflineMessageStatus) []OfflineMessage { + var messages []OfflineMessage + query := DB.Where("kefu_id = ?", kefuId) + if status != "" { + query = query.Where("status = ?", status) + } + query.Order("created_at desc").Find(&messages) + return messages +} + +func FindUnreadOfflineMessagesByKefuId(kefuId string) []OfflineMessage { + return FindOfflineMessagesByStatus(kefuId, OfflineMessageStatusUnread) +} + +func MarkOfflineMessageAsRead(id uint) { + UpdateOfflineMessageStatus(id, OfflineMessageStatusRead) +} + +func IsValidOfflineMessageStatus(status string) bool { + s := OfflineMessageStatus(status) + return s == OfflineMessageStatusUnread || + s == OfflineMessageStatusRead || + s == OfflineMessageStatusReplied +} + +func (s OfflineMessageStatus) String() string { + return string(s) +} + +func GetOfflineMessageStatusText(status OfflineMessageStatus) string { + switch status { + case OfflineMessageStatusUnread: + return "未读" + case OfflineMessageStatusRead: + return "已读" + case OfflineMessageStatusReplied: + return "已回复" + default: + return "未知" + } +} + +func BatchUpdateOfflineMessageStatus(ids []uint, status OfflineMessageStatus) error { + if len(ids) == 0 { + return nil + } + now := time.Now() + return DB.Model(&OfflineMessage{}). + Where("id IN (?)", ids). + Updates(map[string]interface{}{ + "status": status, + "updated_at": now, + }).Error +} + +func BatchMarkAsRead(ids []uint) error { + return BatchUpdateOfflineMessageStatus(ids, OfflineMessageStatusRead) +} + +func GetOfflineMessageStatsByKefuId(kefuId string) map[string]int64 { + stats := make(map[string]int64) + + var unreadCount int64 + DB.Model(&OfflineMessage{}).Where("kefu_id = ? AND status = ?", kefuId, OfflineMessageStatusUnread).Count(&unreadCount) + stats["unread"] = unreadCount + + var readCount int64 + DB.Model(&OfflineMessage{}).Where("kefu_id = ? AND status = ?", kefuId, OfflineMessageStatusRead).Count(&readCount) + stats["read"] = readCount + + var repliedCount int64 + DB.Model(&OfflineMessage{}).Where("kefu_id = ? AND status = ?", kefuId, OfflineMessageStatusReplied).Count(&repliedCount) + stats["replied"] = repliedCount + + var totalCount int64 + DB.Model(&OfflineMessage{}).Where("kefu_id = ?", kefuId).Count(&totalCount) + stats["total"] = totalCount + + return stats +} + +func FindOfflineMessagesByVisitorId(visitorId string) []OfflineMessage { + var messages []OfflineMessage + DB.Where("visitor_id = ?", visitorId).Order("created_at desc").Find(&messages) + return messages +} + +func FindLastOfflineMessageByVisitorId(visitorId string) OfflineMessage { + var msg OfflineMessage + DB.Where("visitor_id = ?", visitorId).Order("created_at desc").First(&msg) + return msg +} diff --git a/router/api.go b/router/api.go index 36ba4cc..ee038e0 100644 --- a/router/api.go +++ b/router/api.go @@ -91,6 +91,22 @@ func InitApiRouter(engine *gin.Engine) { kefuGroup.GET("/chartStatistics", controller.GetChartStatistic) kefuGroup.POST("/message", controller.SendKefuMessage) } + //离线留言接口 - 访客端 + engine.POST("/offline_message", middleware.Ipblack, controller.SubmitOfflineMessage) + engine.GET("/kefu/online", controller.CheckKefuOnline) + + //离线留言接口 - 客服端(需要认证) + offlineMessageGroup := engine.Group("/offline_messages") + offlineMessageGroup.Use(middleware.JwtApiMiddleware) + { + offlineMessageGroup.GET("", controller.GetOfflineMessages) + offlineMessageGroup.GET("/stats", controller.GetOfflineMessageStats) + offlineMessageGroup.GET("/:id", controller.GetOfflineMessageDetail) + offlineMessageGroup.POST("/reply", controller.ReplyOfflineMessage) + offlineMessageGroup.POST("/:id/status", controller.UpdateOfflineMessageStatus) + offlineMessageGroup.DELETE("/:id", controller.DeleteOfflineMessage) + offlineMessageGroup.POST("/mark_read", controller.BatchMarkOfflineMessagesAsRead) + } //微信接口 engine.GET("/micro_program", middleware.JwtApiMiddleware, controller.GetCheckWeixinSign) } diff --git a/static/css/common.css b/static/css/common.css index 373c74c..d9f0280 100644 --- a/static/css/common.css +++ b/static/css/common.css @@ -583,4 +583,82 @@ a{color: #07a9fe;text-decoration: none;} /* 定义滚动条滑块在 hover 状态下的样式 */ ::-webkit-scrollbar-thumb:hover { background-color: #999; +} + +/* 离线留言表单样式 */ +.offlineMessageContainer { + height: 100%; + padding: 20px; + background: #f5f5f5; + overflow-y: auto; +} + +.offlineMessageTitle { + text-align: center; + margin-bottom: 20px; +} + +.offlineMessageTitle h3 { + color: #303133; + font-size: 18px; + margin-bottom: 10px; +} + +.offlineMessageTitle h3 i { + margin-right: 8px; + color: #409EFF; +} + +.offlineMessageTitle p { + color: #909399; + font-size: 14px; +} + +.offlineMessageForm { + max-width: 500px; + margin: 0 auto; + background: #fff; + padding: 30px; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); +} + +.offlineMessageForm .el-form-item { + margin-bottom: 20px; +} + +.offlineMessageForm .el-form-item__label { + color: #606266; + font-weight: 500; +} + +.offlineMessageForm .el-textarea__inner { + resize: none; +} + +.offlineMessageSuccess { + max-width: 500px; + margin: 20px auto 0; +} + +.offlineMessageSuccess .el-alert { + border-radius: 8px; +} + +@media screen and (max-width: 600px) { + .offlineMessageContainer { + padding: 15px; + } + + .offlineMessageForm { + padding: 20px; + } + + .offlineMessageTitle h3 { + font-size: 16px; + } + + .offlineMessageTitle p { + font-size: 12px; + } } \ No newline at end of file diff --git a/static/templates/chat_main.html b/static/templates/chat_main.html index 755a6a6..301a970 100644 --- a/static/templates/chat_main.html +++ b/static/templates/chat_main.html @@ -75,6 +75,52 @@ :total="visitorCount"> + +
+ + + + Unread + Read + Replied + + + + Mark All Read + + + +
+ + + + + + +
+ <{item.visitor_name}> + New + Replied +
+
<{item.subject}>
+
+ <{item.created_at}> +
+
+
+
+ + + +
+
@@ -307,6 +353,91 @@ Cancel + + + + + + <{selectedOfflineMessage.visitor_name}> + + + Unread + Read + Replied + + + <{selectedOfflineMessage.visitor_email}> + + + <{selectedOfflineMessage.visitor_phone}> + + + <{selectedOfflineMessage.subject}> + + + <{selectedOfflineMessage.created_at}> + + + +
+
Message Content:
+ + +
+ + + Delete + Reply + Close + +
+ + + +
+
Original Message:
+ + +
+ +
+
Reply Content:
+ + +
+ + Cancel + Send Reply + +
@@ -375,6 +506,20 @@ pagesize:15, list:[], }, + offlineMessageStatus:'unread', + offlineMessageCurrentPage:1, + offlineMessagePageSize:20, + offlineMessageCount:0, + offlineMessages:[], + selectedOfflineMessage:null, + offlineMessageStats:{ + unread:0, + read:0, + replied:0, + }, + offlineMessageDetailDialog:false, + offlineMessageReplyDialog:false, + offlineMessageReplyContent:'', }, methods: { //跳转 @@ -851,6 +996,10 @@ if(tab.name=="second"){ this.getVisitorPage(1); } + if(tab.name=="third"){ + this.getOfflineMessageStats(); + this.getOfflineMessages(); + } if(tab.name=="blackList"){ } }, @@ -1175,6 +1324,118 @@ }); }); }, + //获取离线留言统计 + getOfflineMessageStats(){ + let _this=this; + this.sendAjax("/offline_messages/stats","get",{},function(result){ + _this.offlineMessageStats=result; + }); + }, + //获取离线留言列表 + getOfflineMessages(){ + let _this=this; + let params={ + status:this.offlineMessageStatus, + page:this.offlineMessageCurrentPage, + pagesize:this.offlineMessagePageSize, + }; + this.sendAjax("/offline_messages","get",params,function(result){ + _this.offlineMessages=result.list?result.list:[]; + _this.offlineMessageCount=result.count?result.count:0; + if(_this.offlineMessages.length>0){ + _this.selectedOfflineMessage=_this.offlineMessages[0]; + } + }); + }, + //状态筛选改变 + offlineMessageStatusChange(){ + this.offlineMessageCurrentPage=1; + this.getOfflineMessages(); + }, + //分页改变 + offlineMessagePageChange(page){ + this.offlineMessageCurrentPage=page; + this.getOfflineMessages(); + }, + //查看离线留言详情 + viewOfflineMessageDetail(item){ + let _this=this; + this.selectedOfflineMessage=item; + this.offlineMessageDetailDialog=true; + if(item.status=='unread'){ + this.sendAjax("/offline_messages/"+item.id+"/status","post",{status:'read'},function(result){ + _this.getOfflineMessages(); + _this.getOfflineMessageStats(); + }); + } + }, + //回复离线留言 + openReplyOfflineMessageDialog(){ + this.offlineMessageReplyDialog=true; + this.offlineMessageReplyContent=''; + }, + //提交回复 + submitOfflineMessageReply(){ + if(!this.offlineMessageReplyContent.trim()){ + this.$message({ + message: 'Please enter reply content', + type: 'warning' + }); + return; + } + let _this=this; + let params={ + id:this.selectedOfflineMessage.id, + reply_content:this.offlineMessageReplyContent, + }; + this.sendAjax("/offline_messages/reply","post",params,function(result){ + _this.offlineMessageReplyDialog=false; + _this.offlineMessageDetailDialog=false; + _this.$message({ + message: 'Reply sent successfully', + type: 'success' + }); + _this.getOfflineMessages(); + _this.getOfflineMessageStats(); + }); + }, + //批量标记已读 + batchMarkOfflineMessagesAsRead(){ + let _this=this; + this.$confirm('Mark all unread messages as read?', 'Confirm', { + confirmButtonText: 'Confirm', + cancelButtonText: 'Cancel', + type: 'warning' + }).then(() => { + _this.sendAjax("/offline_messages/mark_read","post",{},function(result){ + _this.$message({ + message: 'Marked as read successfully', + type: 'success' + }); + _this.getOfflineMessages(); + _this.getOfflineMessageStats(); + }); + }).catch(() => {}); + }, + //删除离线留言 + deleteOfflineMessage(){ + let _this=this; + this.$confirm('Delete this message?', 'Confirm', { + confirmButtonText: 'Confirm', + cancelButtonText: 'Cancel', + type: 'warning' + }).then(() => { + _this.sendAjax("/offline_messages/"+_this.selectedOfflineMessage.id,"delete",{},function(result){ + _this.offlineMessageDetailDialog=false; + _this.$message({ + message: 'Deleted successfully', + type: 'success' + }); + _this.getOfflineMessages(); + _this.getOfflineMessageStats(); + }); + }).catch(() => {}); + }, //划词搜索 selectText(){ return false; @@ -1248,6 +1509,7 @@ this.getOnlineVisitors(); this.getReplys(); this.getIpblacks(); + this.getOfflineMessageStats(); this.selectText(); //心跳 this.ping(); diff --git a/static/templates/chat_page.html b/static/templates/chat_page.html index 0997a38..16e64b7 100644 --- a/static/templates/chat_page.html +++ b/static/templates/chat_page.html @@ -28,7 +28,72 @@
Live Chat Support
-
+ + +
+
+
+

客服离线,请留言

+

我们的客服目前不在线,请填写以下表单,客服上线后会尽快回复您。

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + 提交留言 + + + 尝试重新连接 + + + + +
+ + + + 继续留言 + +
+
+
+ + +
@@ -121,6 +186,31 @@ pagesize:5, list:[], }, + showOfflineMessageForm: false, + offlineMessageSubmitting: false, + offlineMessageSubmitted: false, + offlineMessageForm: { + visitor_name: '', + visitor_email: '', + visitor_phone: '', + subject: '', + content: '', + }, + offlineMessageRules: { + visitor_name: [ + { required: true, message: '请输入您的姓名', trigger: 'blur' } + ], + visitor_email: [ + { required: true, message: '请输入您的邮箱', trigger: 'blur' }, + { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' } + ], + subject: [ + { required: true, message: '请输入留言主题', trigger: 'blur' } + ], + content: [ + { required: true, message: '请输入留言内容', trigger: 'blur' } + ], + }, }, methods: { initConn:function() { @@ -577,6 +667,83 @@ $(".chatBox").append("
"+title+"
"); this.scrollBottom(); }, + checkKefuOnline:function() { + let _this = this; + $.get("/kefu/online?kefu_id="+KEFU_ID, function(res) { + if(res.code == 200 && res.result) { + if(res.result.is_online) { + _this.showOfflineMessageForm = false; + _this.getUserInfo(); + } else { + _this.showOfflineMessageForm = true; + _this.kefuInfo.avatar = res.result.avator; + } + } else { + _this.showOfflineMessageForm = true; + } + }).fail(function() { + _this.showOfflineMessageForm = true; + }); + }, + submitOfflineMessage:function() { + let _this = this; + this.$refs.offlineMessageForm.validate(function(valid) { + if(valid) { + _this.offlineMessageSubmitting = true; + + let formData = { + kefu_id: KEFU_ID, + visitor_name: _this.offlineMessageForm.visitor_name, + visitor_email: _this.offlineMessageForm.visitor_email, + visitor_phone: _this.offlineMessageForm.visitor_phone, + subject: _this.offlineMessageForm.subject, + content: _this.offlineMessageForm.content, + refer: REFER, + }; + + if(_this.visitor && _this.visitor.visitor_id) { + formData.visitor_id = _this.visitor.visitor_id; + } + + $.post("/offline_message", formData, function(res) { + _this.offlineMessageSubmitting = false; + if(res.code == 200) { + _this.offlineMessageSubmitted = true; + } else { + _this.$message({ + message: res.msg || '提交失败,请稍后重试', + type: 'error' + }); + } + }).fail(function() { + _this.offlineMessageSubmitting = false; + _this.$message({ + message: '提交失败,请稍后重试', + type: 'error' + }); + }); + } + }); + }, + resetOfflineMessageForm:function() { + this.offlineMessageSubmitted = false; + this.offlineMessageForm = { + visitor_name: '', + visitor_email: '', + visitor_phone: '', + subject: '', + content: '', + }; + this.$refs.offlineMessageForm.resetFields(); + }, + tryReconnect:function() { + let _this = this; + _this.$message({ + message: '正在尝试重新连接...', + type: 'info' + }); + this.checkKefuOnline(); + }, }, mounted:function() { document.addEventListener('paste', this.onPasteUpload) @@ -584,7 +751,7 @@ }, created: function () { this.init(); - this.getUserInfo(); + this.checkKefuOnline(); } })