支持客户、对话记录与统计报告 CSV 导出
- 新增 /customers/export、/sessions/export、/statistics/export - 沿用列表筛选与角色可见范围,UTF-8 BOM 便于 Excel 打开 - 前端三处导出按钮接入真实下载
This commit is contained in:
@@ -0,0 +1,375 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"kefu-sys/server/internal/middleware"
|
||||||
|
"kefu-sys/server/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Export 导出客户列表 CSV(沿用列表筛选与坐席可见范围)。
|
||||||
|
// GET /api/customers/export
|
||||||
|
func (h *CustomerHandler) Export(c *gin.Context) {
|
||||||
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
search := c.Query("search")
|
||||||
|
status := c.Query("status")
|
||||||
|
source := c.Query("source")
|
||||||
|
|
||||||
|
query := model.DB.Where("tenant_id = ?", tenantID)
|
||||||
|
if middleware.GetRole(c) == "agent" {
|
||||||
|
assignedCustomers := model.DB.Model(&model.Session{}).
|
||||||
|
Select("customer_id").
|
||||||
|
Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c))
|
||||||
|
query = query.Where("id IN (?)", assignedCustomers)
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
query = query.Where("name LIKE ? OR phone LIKE ? OR email LIKE ?", "%"+search+"%", "%"+search+"%", "%"+search+"%")
|
||||||
|
}
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if source != "" {
|
||||||
|
query = query.Where("source = ?", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
var customers []model.Customer
|
||||||
|
if err := query.Order("updated_at desc").Limit(exportMaxRows).Find(&customers).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出客户失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
header := []string{"ID", "姓名", "手机", "邮箱", "来源", "状态", "标签", "对话次数", "最近联系", "创建时间"}
|
||||||
|
rows := make([][]string, 0, len(customers))
|
||||||
|
for _, cu := range customers {
|
||||||
|
rows = append(rows, []string{
|
||||||
|
strconv.FormatUint(uint64(cu.ID), 10),
|
||||||
|
cu.Name,
|
||||||
|
cu.Phone,
|
||||||
|
cu.Email,
|
||||||
|
cu.Source,
|
||||||
|
cu.Status,
|
||||||
|
cu.Tags,
|
||||||
|
strconv.Itoa(cu.ConversationCount),
|
||||||
|
formatCSVTimePtr(cu.LastContactAt),
|
||||||
|
formatCSVTime(cu.CreatedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("customers_%s.csv", time.Now().Format("20060102_150405"))
|
||||||
|
writeCSVResponse(c, name, header, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export 导出会话列表 CSV(沿用列表筛选与权限)。
|
||||||
|
// GET /api/sessions/export
|
||||||
|
func (h *SessionHandler) Export(c *gin.Context) {
|
||||||
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
status := c.Query("status")
|
||||||
|
priority := c.Query("priority")
|
||||||
|
agentID := c.Query("agent_id")
|
||||||
|
channelID := c.Query("channel_id")
|
||||||
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
|
from := c.Query("from")
|
||||||
|
to := c.Query("to")
|
||||||
|
|
||||||
|
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID)
|
||||||
|
if middleware.GetRole(c) == "agent" {
|
||||||
|
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
|
||||||
|
}
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("sessions.status = ?", status)
|
||||||
|
}
|
||||||
|
if priority != "" {
|
||||||
|
query = query.Where("sessions.priority = ?", priority)
|
||||||
|
}
|
||||||
|
if agentID != "" {
|
||||||
|
query = query.Where("sessions.agent_id = ?", agentID)
|
||||||
|
}
|
||||||
|
if channelID != "" {
|
||||||
|
query = query.Where("sessions.channel_id = ?", channelID)
|
||||||
|
}
|
||||||
|
if from != "" {
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", from, time.Local); err == nil {
|
||||||
|
query = query.Where("sessions.created_at >= ?", t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if to != "" {
|
||||||
|
if t, err := time.ParseInLocation("2006-01-02", to, time.Local); err == nil {
|
||||||
|
query = query.Where("sessions.created_at < ?", t.Add(24*time.Hour))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if search != "" {
|
||||||
|
like := "%" + search + "%"
|
||||||
|
query = query.Joins("LEFT JOIN customers ON customers.id = sessions.customer_id").
|
||||||
|
Where("customers.name LIKE ? OR CAST(sessions.id AS TEXT) LIKE ? OR COALESCE(sessions.end_reason, '') LIKE ?", like, like, like)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessions []model.Session
|
||||||
|
if err := query.Order("sessions.created_at desc").Limit(exportMaxRows).Find(&sessions).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出会话失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
customerIDs := make([]uint, 0)
|
||||||
|
agentIDs := make([]uint, 0)
|
||||||
|
channelIDs := make([]uint, 0)
|
||||||
|
sessionIDs := make([]uint, 0, len(sessions))
|
||||||
|
for _, s := range sessions {
|
||||||
|
sessionIDs = append(sessionIDs, s.ID)
|
||||||
|
customerIDs = append(customerIDs, s.CustomerID)
|
||||||
|
channelIDs = append(channelIDs, s.ChannelID)
|
||||||
|
if s.AgentID != nil {
|
||||||
|
agentIDs = append(agentIDs, *s.AgentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customerMap := map[uint]model.Customer{}
|
||||||
|
if len(customerIDs) > 0 {
|
||||||
|
var customers []model.Customer
|
||||||
|
model.DB.Where("id IN ?", uniqueUint(customerIDs)).Find(&customers)
|
||||||
|
for _, cu := range customers {
|
||||||
|
customerMap[cu.ID] = cu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
agentMap := map[uint]model.User{}
|
||||||
|
if len(agentIDs) > 0 {
|
||||||
|
var users []model.User
|
||||||
|
model.DB.Where("id IN ?", uniqueUint(agentIDs)).Find(&users)
|
||||||
|
for _, u := range users {
|
||||||
|
agentMap[u.ID] = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channelMap := map[uint]model.Channel{}
|
||||||
|
if len(channelIDs) > 0 {
|
||||||
|
var channels []model.Channel
|
||||||
|
model.DB.Where("id IN ?", uniqueUint(channelIDs)).Find(&channels)
|
||||||
|
for _, ch := range channels {
|
||||||
|
channelMap[ch.ID] = ch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msgCountMap := map[uint]int{}
|
||||||
|
if len(sessionIDs) > 0 {
|
||||||
|
type row struct {
|
||||||
|
SessionID uint
|
||||||
|
Cnt int
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
model.DB.Model(&model.Message{}).
|
||||||
|
Select("session_id, count(*) as cnt").
|
||||||
|
Where("session_id IN ?", sessionIDs).
|
||||||
|
Group("session_id").
|
||||||
|
Scan(&rows)
|
||||||
|
for _, r := range rows {
|
||||||
|
msgCountMap[r.SessionID] = r.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一条消息摘要(按会话 max(seq) 批量取)
|
||||||
|
lastMsgMap := map[uint]string{}
|
||||||
|
if len(sessionIDs) > 0 {
|
||||||
|
type lastRow struct {
|
||||||
|
SessionID uint
|
||||||
|
Content string
|
||||||
|
Type string
|
||||||
|
}
|
||||||
|
var lastRows []lastRow
|
||||||
|
_ = model.DB.Raw(`
|
||||||
|
SELECT m.session_id AS session_id, m.content AS content, m.type AS type
|
||||||
|
FROM messages m
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT session_id, MAX(seq) AS max_seq
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id IN ?
|
||||||
|
GROUP BY session_id
|
||||||
|
) t ON m.session_id = t.session_id AND m.seq = t.max_seq
|
||||||
|
`, sessionIDs).Scan(&lastRows).Error
|
||||||
|
for _, r := range lastRows {
|
||||||
|
if r.Type == "image" {
|
||||||
|
lastMsgMap[r.SessionID] = "[图片]"
|
||||||
|
} else {
|
||||||
|
lastMsgMap[r.SessionID] = r.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header := []string{
|
||||||
|
"会话ID", "客户", "客户手机", "坐席", "渠道", "状态", "优先级",
|
||||||
|
"消息数", "满意度", "评价内容", "结束原因", "最后消息", "创建时间", "结束时间",
|
||||||
|
}
|
||||||
|
rows := make([][]string, 0, len(sessions))
|
||||||
|
for _, s := range sessions {
|
||||||
|
custName, custPhone := "", ""
|
||||||
|
if cu, ok := customerMap[s.CustomerID]; ok {
|
||||||
|
custName, custPhone = cu.Name, cu.Phone
|
||||||
|
}
|
||||||
|
agentName := ""
|
||||||
|
if s.AgentID != nil {
|
||||||
|
if u, ok := agentMap[*s.AgentID]; ok {
|
||||||
|
agentName = u.Nickname
|
||||||
|
if agentName == "" {
|
||||||
|
agentName = u.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chName := ""
|
||||||
|
if ch, ok := channelMap[s.ChannelID]; ok {
|
||||||
|
chName = ch.Name
|
||||||
|
if chName == "" {
|
||||||
|
chName = ch.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{
|
||||||
|
strconv.FormatUint(uint64(s.ID), 10),
|
||||||
|
custName,
|
||||||
|
custPhone,
|
||||||
|
agentName,
|
||||||
|
chName,
|
||||||
|
s.Status,
|
||||||
|
s.Priority,
|
||||||
|
strconv.Itoa(msgCountMap[s.ID]),
|
||||||
|
formatIntPtr(s.SatisfactionScore),
|
||||||
|
s.SatisfactionText,
|
||||||
|
s.EndReason,
|
||||||
|
lastMsgMap[s.ID],
|
||||||
|
formatCSVTime(s.CreatedAt),
|
||||||
|
formatCSVTimePtr(s.EndedAt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("sessions_%s.csv", time.Now().Format("20060102_150405"))
|
||||||
|
writeCSVResponse(c, name, header, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export 导出统计报告 CSV(KPI + 坐席绩效 + 渠道分布)。
|
||||||
|
// GET /api/statistics/export
|
||||||
|
func (h *StatisticsHandler) Export(c *gin.Context) {
|
||||||
|
if !requireStatisticsAccess(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
sessions, messages, err := loadStatisticsData(tenantID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出统计失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
responses := firstResponseSeconds(messages)
|
||||||
|
var responseTotal float64
|
||||||
|
for _, seconds := range responses {
|
||||||
|
responseTotal += seconds
|
||||||
|
}
|
||||||
|
var satisfactionTotal float64
|
||||||
|
satisfactionCount := 0
|
||||||
|
for _, session := range sessions {
|
||||||
|
if session.SatisfactionScore != nil {
|
||||||
|
satisfactionTotal += float64(*session.SatisfactionScore)
|
||||||
|
satisfactionCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
avgResponse := float64(0)
|
||||||
|
if len(responses) > 0 {
|
||||||
|
avgResponse = responseTotal / float64(len(responses))
|
||||||
|
}
|
||||||
|
satisfactionAvg := float64(0)
|
||||||
|
if satisfactionCount > 0 {
|
||||||
|
satisfactionAvg = satisfactionTotal / float64(satisfactionCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 坐席绩效
|
||||||
|
var agents []model.User
|
||||||
|
model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents)
|
||||||
|
type perf struct {
|
||||||
|
Name string
|
||||||
|
Conversations int
|
||||||
|
ResponseTotal float64
|
||||||
|
ResponseCount int
|
||||||
|
Satisfaction float64
|
||||||
|
RatingCount int
|
||||||
|
}
|
||||||
|
items := make(map[uint]*perf)
|
||||||
|
for _, agent := range agents {
|
||||||
|
name := agent.Nickname
|
||||||
|
if name == "" {
|
||||||
|
name = agent.Username
|
||||||
|
}
|
||||||
|
items[agent.ID] = &perf{Name: name}
|
||||||
|
}
|
||||||
|
for _, session := range sessions {
|
||||||
|
if session.AgentID == nil || items[*session.AgentID] == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := items[*session.AgentID]
|
||||||
|
item.Conversations++
|
||||||
|
if seconds, exists := responses[session.ID]; exists {
|
||||||
|
item.ResponseTotal += seconds
|
||||||
|
item.ResponseCount++
|
||||||
|
}
|
||||||
|
if session.SatisfactionScore != nil {
|
||||||
|
item.Satisfaction += float64(*session.SatisfactionScore)
|
||||||
|
item.RatingCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渠道
|
||||||
|
var channels []model.Channel
|
||||||
|
model.DB.Where("tenant_id = ?", tenantID).Find(&channels)
|
||||||
|
channelTypes := make(map[uint]string)
|
||||||
|
for _, channel := range channels {
|
||||||
|
channelTypes[channel.ID] = channel.Type
|
||||||
|
}
|
||||||
|
labels := map[string]string{"web": "网页", "wechat": "微信", "app": "APP", "phone": "电话工单", "email": "邮件"}
|
||||||
|
chCounts := make(map[string]int)
|
||||||
|
for _, session := range sessions {
|
||||||
|
channelType := channelTypes[session.ChannelID]
|
||||||
|
if channelType == "" {
|
||||||
|
channelType = "unknown"
|
||||||
|
}
|
||||||
|
chCounts[channelType]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扁平 CSV:分节用 section 列
|
||||||
|
header := []string{"区块", "指标/坐席/渠道", "数值1", "数值2", "数值3", "备注"}
|
||||||
|
rows := make([][]string, 0, 32+len(items)+len(chCounts))
|
||||||
|
rows = append(rows,
|
||||||
|
[]string{"概览", "总会话数", strconv.Itoa(len(sessions)), "", "", ""},
|
||||||
|
[]string{"概览", "总消息数", strconv.Itoa(len(messages)), "", "", ""},
|
||||||
|
[]string{"概览", "平均首次响应(秒)", fmt.Sprintf("%.1f", avgResponse), "", "", ""},
|
||||||
|
[]string{"概览", "平均满意度", fmt.Sprintf("%.2f", satisfactionAvg), "", "", fmt.Sprintf("样本 %d", satisfactionCount)},
|
||||||
|
[]string{"概览", "一次解决率(%)", fmt.Sprintf("%.1f", firstResolveRate(sessions)), "", "", ""},
|
||||||
|
[]string{"概览", "导出时间", time.Now().Format("2006-01-02 15:04:05"), "", "", ""},
|
||||||
|
)
|
||||||
|
rows = append(rows, []string{"坐席绩效", "坐席", "会话数", "平均响应(秒)", "满意度", ""})
|
||||||
|
for _, agent := range agents {
|
||||||
|
item := items[agent.ID]
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
avgR := float64(0)
|
||||||
|
if item.ResponseCount > 0 {
|
||||||
|
avgR = item.ResponseTotal / float64(item.ResponseCount)
|
||||||
|
}
|
||||||
|
sat := float64(0)
|
||||||
|
if item.RatingCount > 0 {
|
||||||
|
sat = item.Satisfaction / float64(item.RatingCount)
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{
|
||||||
|
"坐席绩效",
|
||||||
|
item.Name,
|
||||||
|
strconv.Itoa(item.Conversations),
|
||||||
|
fmt.Sprintf("%.1f", avgR),
|
||||||
|
fmt.Sprintf("%.2f", sat),
|
||||||
|
"",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{"渠道分布", "渠道", "会话数", "", "", ""})
|
||||||
|
for typ, cnt := range chCounts {
|
||||||
|
label := labels[typ]
|
||||||
|
if label == "" {
|
||||||
|
label = typ
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{"渠道分布", label, strconv.Itoa(cnt), "", "", ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
name := fmt.Sprintf("statistics_%s.csv", time.Now().Format("20060102_150405"))
|
||||||
|
writeCSVResponse(c, name, header, rows)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const exportMaxRows = 5000
|
||||||
|
|
||||||
|
func csvEscape(s string) string {
|
||||||
|
return strings.ReplaceAll(s, "\r\n", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCSVResponse(c *gin.Context, filename string, header []string, rows [][]string) {
|
||||||
|
if !strings.HasSuffix(strings.ToLower(filename), ".csv") {
|
||||||
|
filename += ".csv"
|
||||||
|
}
|
||||||
|
// 兼容 Excel 中文:UTF-8 BOM
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filename)))
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
|
||||||
|
if _, err := c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w := csv.NewWriter(c.Writer)
|
||||||
|
_ = w.Write(header)
|
||||||
|
for _, row := range rows {
|
||||||
|
for i := range row {
|
||||||
|
row[i] = csvEscape(row[i])
|
||||||
|
}
|
||||||
|
_ = w.Write(row)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCSVTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.In(time.Local).Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatCSVTimePtr(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return formatCSVTime(*t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatUintPtr(v *uint) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.FormatUint(uint64(*v), 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatIntPtr(v *int) string {
|
||||||
|
if v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(*v)
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
authRequired.GET("/agents/available", session.ListAvailableAgents)
|
authRequired.GET("/agents/available", session.ListAvailableAgents)
|
||||||
sessions := authRequired.Group("/sessions")
|
sessions := authRequired.Group("/sessions")
|
||||||
sessions.GET("", session.List)
|
sessions.GET("", session.List)
|
||||||
|
sessions.GET("/export", session.Export)
|
||||||
sessions.GET("/:id", session.Get)
|
sessions.GET("/:id", session.Get)
|
||||||
sessions.POST("", session.Create)
|
sessions.POST("", session.Create)
|
||||||
sessions.POST("/batch-archive", session.BatchArchive)
|
sessions.POST("/batch-archive", session.BatchArchive)
|
||||||
@@ -69,6 +70,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
// 客户管理
|
// 客户管理
|
||||||
customers := authRequired.Group("/customers")
|
customers := authRequired.Group("/customers")
|
||||||
customers.GET("", customer.List)
|
customers.GET("", customer.List)
|
||||||
|
customers.GET("/export", customer.Export)
|
||||||
customers.GET("/:id", customer.Get)
|
customers.GET("/:id", customer.Get)
|
||||||
customers.POST("", customer.Create)
|
customers.POST("", customer.Create)
|
||||||
customers.PUT("/:id", customer.Update)
|
customers.PUT("/:id", customer.Update)
|
||||||
@@ -110,6 +112,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
statistics.GET("/response-distribution", stats.ResponseDistribution)
|
statistics.GET("/response-distribution", stats.ResponseDistribution)
|
||||||
statistics.GET("/performance", stats.AgentPerformance)
|
statistics.GET("/performance", stats.AgentPerformance)
|
||||||
statistics.GET("/channels", stats.ChannelDistribution)
|
statistics.GET("/channels", stats.ChannelDistribution)
|
||||||
|
statistics.GET("/export", stats.Export)
|
||||||
|
|
||||||
// 管理端接口(需要管理员权限)
|
// 管理端接口(需要管理员权限)
|
||||||
adminGroup := authRequired.Group("/admin")
|
adminGroup := authRequired.Group("/admin")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import dayjs, { type Dayjs } from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import {
|
import {
|
||||||
archiveSession, batchArchiveSessions, getAvailableAgents, getChannels, getSession, getSessions,
|
archiveSession, batchArchiveSessions, exportSessionsCSV, getAvailableAgents, getChannels, getSession, getSessions,
|
||||||
type AvailableAgent, type Channel, type Message, type Session, type SessionEvent,
|
type AvailableAgent, type Channel, type Message, type Session, type SessionEvent,
|
||||||
} from '@/services/api'
|
} from '@/services/api'
|
||||||
import { ChatImage } from '@/components/common/ImagePreview'
|
import { ChatImage } from '@/components/common/ImagePreview'
|
||||||
@@ -162,6 +162,7 @@ const ChatHistory = () => {
|
|||||||
const [detailLoading, setDetailLoading] = useState(false)
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
const [checkedIds, setCheckedIds] = useState<number[]>([])
|
const [checkedIds, setCheckedIds] = useState<number[]>([])
|
||||||
const [archiving, setArchiving] = useState(false)
|
const [archiving, setArchiving] = useState(false)
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -335,11 +336,29 @@ const ChatHistory = () => {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-1.5 h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 cursor-pointer"
|
disabled={exporting}
|
||||||
onClick={() => message.info('导出功能后续版本提供')}
|
className="flex items-center gap-1.5 h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 cursor-pointer disabled:opacity-50"
|
||||||
|
onClick={async () => {
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
await exportSessionsCSV({
|
||||||
|
status: statusFilter,
|
||||||
|
agent_id: agentFilter,
|
||||||
|
channel_id: channelFilter,
|
||||||
|
search: search || undefined,
|
||||||
|
from: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||||
|
to: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
|
||||||
|
})
|
||||||
|
message.success('对话记录已导出')
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败')
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DownloadOutlined />
|
<DownloadOutlined />
|
||||||
导出
|
{exporting ? '导出中…' : '导出'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
|
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import {
|
import {
|
||||||
createCustomer, deleteCustomer, getCustomer, getCustomers, updateCustomer,
|
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomers, updateCustomer,
|
||||||
type Customer, type Session,
|
type Customer, type Session,
|
||||||
} from '@/services/api'
|
} from '@/services/api'
|
||||||
import { useAuth } from '@/stores/auth'
|
import { useAuth } from '@/stores/auth'
|
||||||
@@ -140,6 +140,7 @@ const Customers = () => {
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
const [tagFilter, setTagFilter] = useState('all')
|
const [tagFilter, setTagFilter] = useState('all')
|
||||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
const [editingId, setEditingId] = useState<number | null>(null)
|
||||||
@@ -321,8 +322,19 @@ const Customers = () => {
|
|||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
<Button
|
<Button
|
||||||
icon={<ExportOutlined />}
|
icon={<ExportOutlined />}
|
||||||
|
loading={exporting}
|
||||||
className="!text-[13px] !h-8 !px-3 !font-medium !text-neutral-600 !border-neutral-200"
|
className="!text-[13px] !h-8 !px-3 !font-medium !text-neutral-600 !border-neutral-200"
|
||||||
onClick={() => message.info('导出功能后续版本提供')}
|
onClick={async () => {
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
await exportCustomersCSV({ search: search || undefined })
|
||||||
|
message.success('客户列表已导出')
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败')
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
导出
|
导出
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { Column, Line, Pie } from '@ant-design/charts'
|
import { Column, Line, Pie } from '@ant-design/charts'
|
||||||
import {
|
import {
|
||||||
getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend,
|
exportStatisticsCSV, getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend,
|
||||||
type StatisticsKpis,
|
type StatisticsKpis,
|
||||||
} from '@/services/api'
|
} from '@/services/api'
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ const Statistics = () => {
|
|||||||
name: string; conversations: number; avgResponse: number; satisfaction: number
|
name: string; conversations: number; avgResponse: number; satisfaction: number
|
||||||
}[]>([])
|
}[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -208,11 +209,22 @@ const Statistics = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 flex items-center gap-1.5 cursor-pointer"
|
disabled={exporting}
|
||||||
onClick={() => message.info('导出报告功能后续版本提供')}
|
className="h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||||
|
onClick={async () => {
|
||||||
|
setExporting(true)
|
||||||
|
try {
|
||||||
|
await exportStatisticsCSV()
|
||||||
|
message.success('统计报告已导出')
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败')
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DownloadOutlined />
|
<DownloadOutlined />
|
||||||
导出报告
|
{exporting ? '导出中…' : '导出报告'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
+35
-1
@@ -1,4 +1,4 @@
|
|||||||
import { get, post, put, del, getList, postForm } from './request'
|
import { get, post, put, del, getList, postForm, downloadFile } from './request'
|
||||||
|
|
||||||
export interface LoginParams { username: string; password: string }
|
export interface LoginParams { username: string; password: string }
|
||||||
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
|
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
|
||||||
@@ -253,6 +253,40 @@ export const getCustomers = (params?: { search?: string; status?: string; source
|
|||||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||||
return getList<Customer>(`/customers?${search}`)
|
return getList<Customer>(`/customers?${search}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const exportCustomersCSV = (params?: { search?: string; status?: string; source?: string }) => {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params?.search) search.set('search', params.search)
|
||||||
|
if (params?.status) search.set('status', params.status)
|
||||||
|
if (params?.source) search.set('source', params.source)
|
||||||
|
const qs = search.toString()
|
||||||
|
return downloadFile(`/customers/export${qs ? `?${qs}` : ''}`, `customers_${Date.now()}.csv`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportSessionsCSV = (params?: {
|
||||||
|
status?: string
|
||||||
|
priority?: string
|
||||||
|
agent_id?: number | string
|
||||||
|
channel_id?: number | string
|
||||||
|
search?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}) => {
|
||||||
|
const search = new URLSearchParams()
|
||||||
|
if (params?.status) search.set('status', params.status)
|
||||||
|
if (params?.priority) search.set('priority', params.priority)
|
||||||
|
if (params?.agent_id != null && params.agent_id !== '') search.set('agent_id', String(params.agent_id))
|
||||||
|
if (params?.channel_id != null && params.channel_id !== '') search.set('channel_id', String(params.channel_id))
|
||||||
|
if (params?.search) search.set('search', params.search)
|
||||||
|
if (params?.from) search.set('from', params.from)
|
||||||
|
if (params?.to) search.set('to', params.to)
|
||||||
|
const qs = search.toString()
|
||||||
|
return downloadFile(`/sessions/export${qs ? `?${qs}` : ''}`, `sessions_${Date.now()}.csv`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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[] }>(`/customers/${id}`)
|
||||||
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data)
|
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data)
|
||||||
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
||||||
|
|||||||
@@ -53,3 +53,38 @@ export const getList = <T>(url: string) => request<ListResponse<T>>(url)
|
|||||||
export const postForm = <T>(url: string, form: FormData) =>
|
export const postForm = <T>(url: string, form: FormData) =>
|
||||||
request<Response<T>>(url, { method: 'POST', body: form })
|
request<Response<T>>(url, { method: 'POST', body: form })
|
||||||
|
|
||||||
|
/** 下载 CSV 等二进制响应(带鉴权) */
|
||||||
|
export async function downloadFile(path: string, fallbackName: string) {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`
|
||||||
|
const res = await fetch(`${BASE}${path}`, { headers })
|
||||||
|
const ct = res.headers.get('Content-Type') || ''
|
||||||
|
if (!res.ok || ct.includes('application/json')) {
|
||||||
|
let msg = '导出失败'
|
||||||
|
try {
|
||||||
|
const json = await res.json()
|
||||||
|
if (json?.message) msg = json.message
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
const blob = await res.blob()
|
||||||
|
let filename = fallbackName
|
||||||
|
const cd = res.headers.get('Content-Disposition') || ''
|
||||||
|
const m = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(cd)
|
||||||
|
if (m) {
|
||||||
|
try {
|
||||||
|
filename = decodeURIComponent(m[1] || m[2])
|
||||||
|
} catch {
|
||||||
|
filename = m[1] || m[2] || fallbackName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user