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: + + + + + + + + Reply (Sent at <{selectedOfflineMessage.reply_time}>): + + + + + + + Delete + Reply + Close + + + + + + + Original Message: + + + + + + Reply Content: + + + + + Cancel + Send Reply + +