feat(离线留言): 新增离线留言功能

实现访客端离线留言表单和客服端管理界面
- 添加离线留言数据库表和相关模型操作
- 实现访客端留言表单提交和状态显示
- 添加客服端留言列表、详情查看和回复功能
- 支持留言状态管理和批量操作
- 添加邮件通知功能
- 优化移动端显示适配
pull/55/head
CGW406 3 months ago
parent 67e960ff0c
commit a196947646

@ -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)
}
}

Binary file not shown.

@ -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;
) 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='离线留言表';

@ -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
}

@ -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)
}

@ -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;
}
}

@ -75,6 +75,52 @@
:total="visitorCount">
</el-pagination>
</el-tab-pane>
<el-tab-pane label="Offline Messages" name="third">
<div style="padding: 10px;">
<el-row style="margin-bottom: 10px;">
<el-col :span="18">
<el-radio-group v-model="offlineMessageStatus" @change="offlineMessageStatusChange">
<el-radio-button label="unread">Unread <el-badge :value="offlineMessageStats.unread" :hidden="!offlineMessageStats.unread" class="item"></el-badge></el-radio-button>
<el-radio-button label="read">Read <el-badge :value="offlineMessageStats.read" :hidden="!offlineMessageStats.read" class="item"></el-badge></el-radio-button>
<el-radio-button label="replied">Replied <el-badge :value="offlineMessageStats.replied" :hidden="!offlineMessageStats.replied" class="item"></el-badge></el-radio-button>
</el-radio-group>
</el-col>
<el-col :span="6" style="text-align: right;">
<el-button type="primary" size="small" @click="batchMarkOfflineMessagesAsRead" v-if="offlineMessageStatus=='unread' && offlineMessageStats.unread>0">Mark All Read</el-button>
</el-col>
</el-row>
<el-row v-for="item in offlineMessages" :key="item.id" class="" style="border-bottom: 1px solid #f0f0f0; padding: 8px 0;">
<div style="cursor:pointer" class="onlineUsers" v-bind:class="{'cur': selectedOfflineMessage && selectedOfflineMessage.id==item.id }" v-on:click="viewOfflineMessageDetail(item)">
<el-col :span="4">
<el-badge :value="new" :hidden="item.status!='unread'" class="item">
<el-avatar :size="40" icon="el-icon-message"></el-avatar>
</el-badge>
</el-col>
<el-col :span="16">
<div style="height:20px;overflow: hidden; font-weight: bold;">
<{item.visitor_name}>
<el-tag v-if="item.status=='unread'" size="mini" type="danger" style="margin-left: 5px;">New</el-tag>
<el-tag v-else-if="item.status=='replied'" size="mini" type="success" style="margin-left: 5px;">Replied</el-tag>
</div>
<div class="lastNewMsg" style="margin-top: 2px;"><{item.subject}></div>
<div style="font-size: 11px; color: #999; margin-top: 2px;">
<{item.created_at}>
</div>
</el-col>
</div>
</el-row>
<el-empty v-if="offlineMessages.length==0" description="No messages found"></el-empty>
<el-pagination
v-show="offlineMessageCount>offlineMessagePageSize"
background
@current-change="offlineMessagePageChange"
:current-page="offlineMessageCurrentPage"
layout="prev,pager, next"
:page-size="offlineMessagePageSize"
:total="offlineMessageCount">
</el-pagination>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-col>
@ -307,6 +353,91 @@
<el-button @click="editReplyContentDialog = false">Cancel</el-button>
</span>
</el-dialog>
<!-- Offline Message Detail Dialog -->
<el-dialog title="Offline Message Detail" :visible.sync="offlineMessageDetailDialog" width="60%" top="0">
<el-descriptions :column="2" border v-if="selectedOfflineMessage">
<el-descriptions-item label="Visitor Name">
<{selectedOfflineMessage.visitor_name}>
</el-descriptions-item>
<el-descriptions-item label="Status">
<el-tag v-if="selectedOfflineMessage.status=='unread'" type="danger">Unread</el-tag>
<el-tag v-else-if="selectedOfflineMessage.status=='read'" type="warning">Read</el-tag>
<el-tag v-else type="success">Replied</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Email">
<{selectedOfflineMessage.visitor_email}>
</el-descriptions-item>
<el-descriptions-item label="Phone">
<{selectedOfflineMessage.visitor_phone}>
</el-descriptions-item>
<el-descriptions-item label="Subject" :span="2">
<{selectedOfflineMessage.subject}>
</el-descriptions-item>
<el-descriptions-item label="Submitted Time" :span="2">
<{selectedOfflineMessage.created_at}>
</el-descriptions-item>
</el-descriptions>
<el-divider></el-divider>
<div style="margin-bottom: 15px;">
<div style="font-weight: bold; margin-bottom: 10px;">Message Content:</div>
<el-input
type="textarea"
:rows="8"
:value="selectedOfflineMessage ? selectedOfflineMessage.content : ''"
readonly
style="background-color: #f9f9f9;">
</el-input>
</div>
<template v-if="selectedOfflineMessage && selectedOfflineMessage.status=='replied'">
<el-divider></el-divider>
<div style="margin-bottom: 15px;">
<div style="font-weight: bold; margin-bottom: 10px;">
<i class="el-icon-reply" style="margin-right: 5px;"></i>Reply (Sent at <{selectedOfflineMessage.reply_time}>):
</div>
<el-input
type="textarea"
:rows="5"
:value="selectedOfflineMessage.reply_content"
readonly
style="background-color: #e6f7ff;">
</el-input>
</div>
</template>
<span slot="footer" class="dialog-footer">
<el-button @click="deleteOfflineMessage" type="danger">Delete</el-button>
<el-button v-if="selectedOfflineMessage && selectedOfflineMessage.status!='replied'" type="primary" @click="openReplyOfflineMessageDialog">Reply</el-button>
<el-button @click="offlineMessageDetailDialog = false">Close</el-button>
</span>
</el-dialog>
<!-- Offline Message Reply Dialog -->
<el-dialog title="Reply Offline Message" :visible.sync="offlineMessageReplyDialog" width="50%" top="0">
<div style="margin-bottom: 15px;">
<div style="font-weight: bold; margin-bottom: 10px;">Original Message:</div>
<el-input
type="textarea"
:rows="4"
:value="selectedOfflineMessage ? selectedOfflineMessage.content : ''"
readonly
style="background-color: #f9f9f9;">
</el-input>
</div>
<el-divider></el-divider>
<div>
<div style="font-weight: bold; margin-bottom: 10px;">Reply Content:</div>
<el-input
type="textarea"
:rows="6"
v-model="offlineMessageReplyContent"
placeholder="Enter your reply here...">
</el-input>
</div>
<span slot="footer" class="dialog-footer">
<el-button @click="offlineMessageReplyDialog = false">Cancel</el-button>
<el-button type="primary" @click="submitOfflineMessageReply">Send Reply</el-button>
</span>
</el-dialog>
</template>
</div>
</body>
@ -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();

@ -28,7 +28,72 @@
<div>Live Chat Support</div>
</div>
</div>
<div class="chatEntBox">
<!-- 离线留言表单 - 当客服不在线时显示 -->
<div class="chatEntBox" v-if="showOfflineMessageForm">
<div class="offlineMessageContainer">
<div class="offlineMessageTitle">
<h3><i class="el-icon-chat-dot-round"></i> 客服离线,请留言</h3>
<p>我们的客服目前不在线,请填写以下表单,客服上线后会尽快回复您。</p>
</div>
<el-form :model="offlineMessageForm" :rules="offlineMessageRules" ref="offlineMessageForm" label-width="80px" class="offlineMessageForm">
<el-form-item label="您的姓名" prop="visitor_name">
<el-input v-model="offlineMessageForm.visitor_name" placeholder="请输入您的姓名"></el-input>
</el-form-item>
<el-form-item label="联系邮箱" prop="visitor_email">
<el-input v-model="offlineMessageForm.visitor_email" placeholder="请输入您的邮箱,方便我们回复"></el-input>
</el-form-item>
<el-form-item label="联系电话" prop="visitor_phone">
<el-input v-model="offlineMessageForm.visitor_phone" placeholder="请输入您的电话(选填)"></el-input>
</el-form-item>
<el-form-item label="留言主题" prop="subject">
<el-input v-model="offlineMessageForm.subject" placeholder="请输入留言主题"></el-input>
</el-form-item>
<el-form-item label="留言内容" prop="content">
<el-input
type="textarea"
:rows="5"
v-model="offlineMessageForm.content"
placeholder="请输入您的留言内容...">
</el-input>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="offlineMessageSubmitting"
@click="submitOfflineMessage"
:disabled="offlineMessageSubmitting">
提交留言
</el-button>
<el-button @click="tryReconnect">
尝试重新连接
</el-button>
</el-form-item>
</el-form>
<div class="offlineMessageSuccess" v-if="offlineMessageSubmitted">
<el-alert
title="留言提交成功!"
type="success"
:closable="false"
show-icon
description="感谢您的留言,客服上线后会尽快与您联系。">
</el-alert>
<el-button type="primary" plain @click="resetOfflineMessageForm" style="margin-top: 15px;">
继续留言
</el-button>
</div>
</div>
</div>
<!-- 正常聊天界面 - 当客服在线时显示 -->
<div class="chatEntBox" v-else>
<div class="chatContext chatVisitorPage">
<div class="chatBox">
<div class="chatNotice" v-on:click="getHistoryMessage" v-show="showLoadMore">
@ -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("<div class='chatNotice'><div class=\"chatNoticeContent\"><span>"+title+"</span></div></div>");
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();
}
})
</script>

Loading…
Cancel
Save