From c43f4208750d97d62c1c31ebb21a83f993bd26b0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 15:10:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=AE=A2=E6=88=B7=E3=80=81?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E8=AE=B0=E5=BD=95=E4=B8=8E=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=20CSV=20=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /customers/export、/sessions/export、/statistics/export - 沿用列表筛选与角色可见范围,UTF-8 BOM 便于 Excel 打开 - 前端三处导出按钮接入真实下载 --- server/internal/handler/export.go | 375 ++++++++++++++++++++++++++ server/internal/handler/export_csv.go | 70 +++++ server/internal/handler/router.go | 3 + web/src/pages/agent/ChatHistory.tsx | 27 +- web/src/pages/agent/Customers.tsx | 16 +- web/src/pages/agent/Statistics.tsx | 20 +- web/src/services/api.ts | 36 ++- web/src/services/request.ts | 35 +++ 8 files changed, 571 insertions(+), 11 deletions(-) create mode 100644 server/internal/handler/export.go create mode 100644 server/internal/handler/export_csv.go diff --git a/server/internal/handler/export.go b/server/internal/handler/export.go new file mode 100644 index 0000000..cacf4f5 --- /dev/null +++ b/server/internal/handler/export.go @@ -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) +} diff --git a/server/internal/handler/export_csv.go b/server/internal/handler/export_csv.go new file mode 100644 index 0000000..564c85f --- /dev/null +++ b/server/internal/handler/export_csv.go @@ -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) +} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index a973ebc..4af08df 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -53,6 +53,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S authRequired.GET("/agents/available", session.ListAvailableAgents) sessions := authRequired.Group("/sessions") sessions.GET("", session.List) + sessions.GET("/export", session.Export) sessions.GET("/:id", session.Get) sessions.POST("", session.Create) 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.GET("", customer.List) + customers.GET("/export", customer.Export) customers.GET("/:id", customer.Get) customers.POST("", customer.Create) 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("/performance", stats.AgentPerformance) statistics.GET("/channels", stats.ChannelDistribution) + statistics.GET("/export", stats.Export) // 管理端接口(需要管理员权限) adminGroup := authRequired.Group("/admin") diff --git a/web/src/pages/agent/ChatHistory.tsx b/web/src/pages/agent/ChatHistory.tsx index b1ba86b..f543694 100644 --- a/web/src/pages/agent/ChatHistory.tsx +++ b/web/src/pages/agent/ChatHistory.tsx @@ -7,7 +7,7 @@ import { } from '@ant-design/icons' import dayjs, { type Dayjs } from 'dayjs' import { - archiveSession, batchArchiveSessions, getAvailableAgents, getChannels, getSession, getSessions, + archiveSession, batchArchiveSessions, exportSessionsCSV, getAvailableAgents, getChannels, getSession, getSessions, type AvailableAgent, type Channel, type Message, type Session, type SessionEvent, } from '@/services/api' import { ChatImage } from '@/components/common/ImagePreview' @@ -162,6 +162,7 @@ const ChatHistory = () => { const [detailLoading, setDetailLoading] = useState(false) const [checkedIds, setCheckedIds] = useState([]) const [archiving, setArchiving] = useState(false) + const [exporting, setExporting] = useState(false) const loadSessions = useCallback(async () => { setLoading(true) @@ -335,11 +336,29 @@ const ChatHistory = () => {
diff --git a/web/src/pages/agent/Statistics.tsx b/web/src/pages/agent/Statistics.tsx index 52d7e8a..79be6e5 100644 --- a/web/src/pages/agent/Statistics.tsx +++ b/web/src/pages/agent/Statistics.tsx @@ -6,7 +6,7 @@ import { } from '@ant-design/icons' import { Column, Line, Pie } from '@ant-design/charts' import { - getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend, + exportStatisticsCSV, getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend, type StatisticsKpis, } from '@/services/api' @@ -34,6 +34,7 @@ const Statistics = () => { name: string; conversations: number; avgResponse: number; satisfaction: number }[]>([]) const [loading, setLoading] = useState(true) + const [exporting, setExporting] = useState(false) useEffect(() => { const load = async () => { @@ -208,11 +209,22 @@ const Statistics = () => {
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 9ecd3eb..fe6f991 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -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 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)) return getList(`/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 createCustomer = (data: Partial) => post('/customers', data) export const updateCustomer = (id: number, data: Partial) => put(`/customers/${id}`, data) diff --git a/web/src/services/request.ts b/web/src/services/request.ts index 0fa04aa..81e2825 100644 --- a/web/src/services/request.ts +++ b/web/src/services/request.ts @@ -53,3 +53,38 @@ export const getList = (url: string) => request>(url) export const postForm = (url: string, form: FormData) => request>(url, { method: 'POST', body: form }) +/** 下载 CSV 等二进制响应(带鉴权) */ +export async function downloadFile(path: string, fallbackName: string) { + const headers: Record = {} + 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) +} +