优化快捷回复联想与个人调用统计,数据统计支持自定义日期
/ 按输入码与个人频次建议(最多10条,唯一自动填入),正文关键字联想;统计页可日历选区间并统一中文日历。
This commit is contained in:
@@ -242,13 +242,18 @@ func (h *SessionHandler) Export(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Export 导出统计报告 CSV(KPI + 坐席绩效 + 渠道分布)。
|
||||
// GET /api/statistics/export
|
||||
// GET /api/statistics/export?from=&to= 或 period=
|
||||
func (h *StatisticsHandler) Export(c *gin.Context) {
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
r, err := parseStatsRange(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
sessions, messages, err := loadStatisticsData(tenantID)
|
||||
sessions, messages, err := loadStatisticsData(tenantID, r)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出统计失败"})
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -213,13 +214,22 @@ func (h *QuickReplyHandler) List(c *gin.Context) {
|
||||
middleware.JSONList(c, list, total, page, pageSize)
|
||||
}
|
||||
|
||||
// Suggest GET /api/quick-replies/suggest?prefix=
|
||||
// 供输入框 / 触发:匹配 shortcut 前缀或标题
|
||||
const suggestLimit = 10
|
||||
|
||||
// Suggest GET /api/quick-replies/suggest
|
||||
// mode=slash&prefix= → / 调用:按输入码前缀过滤;空前缀返回个人调用频次最高的 10 条
|
||||
// mode=keyword&q= → 普通输入:按标题/内容/输入码关键字匹配
|
||||
// 排序一律按当前坐席个人调用次数(非全租户)
|
||||
func (h *QuickReplyHandler) Suggest(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
uid := middleware.GetUserID(c)
|
||||
mode := strings.TrimSpace(c.Query("mode"))
|
||||
if mode == "" {
|
||||
mode = "slash"
|
||||
}
|
||||
prefix := strings.TrimSpace(c.Query("prefix"))
|
||||
prefix = strings.TrimPrefix(strings.ToLower(prefix), "/")
|
||||
q := strings.TrimSpace(c.Query("q"))
|
||||
|
||||
db := model.DB.Model(&model.QuickReply{}).Where("tenant_id = ?", tenantID).Where(
|
||||
"(scope = ? AND status = ?) OR (scope = ? AND owner_user_id = ? AND status = ?)",
|
||||
@@ -227,19 +237,64 @@ func (h *QuickReplyHandler) Suggest(c *gin.Context) {
|
||||
quickReplyScopePersonal, uid, quickReplyStatusPub,
|
||||
)
|
||||
|
||||
switch mode {
|
||||
case "slash":
|
||||
if prefix != "" {
|
||||
like := prefix + "%"
|
||||
titleLike := "%" + prefix + "%"
|
||||
db = db.Where("shortcut LIKE ? OR title LIKE ?", like, titleLike)
|
||||
// 仅匹配输入码前缀(/n → shortcut 以 n 开头)
|
||||
db = db.Where("shortcut <> '' AND LOWER(shortcut) LIKE ?", prefix+"%")
|
||||
}
|
||||
case "keyword":
|
||||
if utf8.RuneCountInString(q) < 1 {
|
||||
middleware.JSON(c, []model.QuickReply{})
|
||||
return
|
||||
}
|
||||
like := "%" + q + "%"
|
||||
db = db.Where("title LIKE ? OR content LIKE ? OR shortcut LIKE ?", like, like, like)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "mode 无效"})
|
||||
return
|
||||
}
|
||||
|
||||
// 多取一些再按个人频次排序截断(避免漏掉个人高频但全局低的条目)
|
||||
var list []model.QuickReply
|
||||
// 无前缀时返回高频
|
||||
order := "usage_count desc, sort_order asc, id desc"
|
||||
if err := db.Order(order).Limit(20).Find(&list).Error; err != nil {
|
||||
if err := db.Order("usage_count desc, sort_order asc, id desc").Limit(200).Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "搜索失败"})
|
||||
return
|
||||
}
|
||||
if len(list) == 0 {
|
||||
middleware.JSON(c, []model.QuickReply{})
|
||||
return
|
||||
}
|
||||
|
||||
ids := make([]uint, len(list))
|
||||
for i, item := range list {
|
||||
ids[i] = item.ID
|
||||
}
|
||||
var usages []model.QuickReplyUserUsage
|
||||
_ = model.DB.Where("user_id = ? AND tenant_id = ? AND quick_reply_id IN ?", uid, tenantID, ids).Find(&usages).Error
|
||||
myMap := map[uint]int{}
|
||||
for _, u := range usages {
|
||||
myMap[u.QuickReplyID] = u.UsageCount
|
||||
}
|
||||
for i := range list {
|
||||
list[i].MyUsageCount = myMap[list[i].ID]
|
||||
}
|
||||
// 个人调用次数优先
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
if list[i].MyUsageCount != list[j].MyUsageCount {
|
||||
return list[i].MyUsageCount > list[j].MyUsageCount
|
||||
}
|
||||
if list[i].UsageCount != list[j].UsageCount {
|
||||
return list[i].UsageCount > list[j].UsageCount
|
||||
}
|
||||
if list[i].SortOrder != list[j].SortOrder {
|
||||
return list[i].SortOrder < list[j].SortOrder
|
||||
}
|
||||
return list[i].ID > list[j].ID
|
||||
})
|
||||
if len(list) > suggestLimit {
|
||||
list = list[:suggestLimit]
|
||||
}
|
||||
middleware.JSON(c, list)
|
||||
}
|
||||
|
||||
@@ -428,7 +483,7 @@ func (h *QuickReplyHandler) Unpublish(c *gin.Context) {
|
||||
middleware.JSON(c, item)
|
||||
}
|
||||
|
||||
// Use POST /api/quick-replies/:id/use 使用计数 +1
|
||||
// Use POST /api/quick-replies/:id/use 个人调用 +1(排序用),并累计全局 usage_count
|
||||
func (h *QuickReplyHandler) Use(c *gin.Context) {
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
item, ok := loadQuickReply(c, uint(id64))
|
||||
@@ -437,6 +492,7 @@ func (h *QuickReplyHandler) Use(c *gin.Context) {
|
||||
}
|
||||
// 可见性:团队已发布,或本人个人
|
||||
uid := middleware.GetUserID(c)
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
if item.Scope == quickReplyScopeTeam && item.Status != quickReplyStatusPub {
|
||||
if !middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "该话术尚未发布"})
|
||||
@@ -447,8 +503,32 @@ func (h *QuickReplyHandler) Use(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权使用"})
|
||||
return
|
||||
}
|
||||
|
||||
// 全局累计(管理页)
|
||||
_ = model.DB.Model(item).UpdateColumn("usage_count", item.UsageCount+1).Error
|
||||
middleware.JSON(c, gin.H{"ok": true, "usage_count": item.UsageCount + 1})
|
||||
|
||||
// 个人累计(建议排序)
|
||||
var usage model.QuickReplyUserUsage
|
||||
err := model.DB.Where("user_id = ? AND quick_reply_id = ?", uid, item.ID).First(&usage).Error
|
||||
myCount := 1
|
||||
if err != nil {
|
||||
usage = model.QuickReplyUserUsage{
|
||||
TenantID: tenantID,
|
||||
UserID: uid,
|
||||
QuickReplyID: item.ID,
|
||||
UsageCount: 1,
|
||||
}
|
||||
_ = model.DB.Create(&usage).Error
|
||||
} else {
|
||||
myCount = usage.UsageCount + 1
|
||||
_ = model.DB.Model(&usage).UpdateColumn("usage_count", myCount).Error
|
||||
}
|
||||
|
||||
middleware.JSON(c, gin.H{
|
||||
"ok": true,
|
||||
"usage_count": item.UsageCount + 1,
|
||||
"my_usage_count": myCount,
|
||||
})
|
||||
}
|
||||
|
||||
// 快捷回复 CSV 固定四列(与导入模板一致)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -22,9 +23,60 @@ func requireStatisticsAccess(c *gin.Context) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func loadStatisticsData(tenantID uint) ([]model.Session, []model.Message, error) {
|
||||
// statsRange 统计时间窗:[From, To) 本地日界
|
||||
type statsRange struct {
|
||||
From time.Time
|
||||
To time.Time // exclusive
|
||||
}
|
||||
|
||||
// parseStatsRange 支持 from&to=YYYY-MM-DD,或 period=today|week|month(默认 week)
|
||||
func parseStatsRange(c *gin.Context) (statsRange, error) {
|
||||
loc := time.Now().Location()
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
|
||||
fromStr := strings.TrimSpace(c.Query("from"))
|
||||
toStr := strings.TrimSpace(c.Query("to"))
|
||||
if fromStr != "" && toStr != "" {
|
||||
fromDay, err1 := time.ParseInLocation("2006-01-02", fromStr, loc)
|
||||
toDay, err2 := time.ParseInLocation("2006-01-02", toStr, loc)
|
||||
if err1 != nil || err2 != nil {
|
||||
return statsRange{}, errStr("日期格式应为 YYYY-MM-DD")
|
||||
}
|
||||
if toDay.Before(fromDay) {
|
||||
return statsRange{}, errStr("结束日期不能早于开始日期")
|
||||
}
|
||||
// 最多 366 天
|
||||
if toDay.Sub(fromDay) > 366*24*time.Hour {
|
||||
return statsRange{}, errStr("日期区间最长 366 天")
|
||||
}
|
||||
return statsRange{From: fromDay, To: toDay.AddDate(0, 0, 1)}, nil
|
||||
}
|
||||
|
||||
period := strings.TrimSpace(c.DefaultQuery("period", "week"))
|
||||
switch period {
|
||||
case "today":
|
||||
return statsRange{From: today, To: today.AddDate(0, 0, 1)}, nil
|
||||
case "month":
|
||||
// 本自然月
|
||||
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
|
||||
nextMonth := monthStart.AddDate(0, 1, 0)
|
||||
return statsRange{From: monthStart, To: nextMonth}, nil
|
||||
case "week", "day":
|
||||
// 近 7 天(含今天)
|
||||
return statsRange{From: today.AddDate(0, 0, -6), To: today.AddDate(0, 0, 1)}, nil
|
||||
default:
|
||||
return statsRange{From: today.AddDate(0, 0, -6), To: today.AddDate(0, 0, 1)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadStatisticsData(tenantID uint, r statsRange) ([]model.Session, []model.Message, error) {
|
||||
var sessions []model.Session
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Order("created_at asc").Find(&sessions).Error; err != nil {
|
||||
q := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if !r.From.IsZero() {
|
||||
q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To)
|
||||
}
|
||||
if err := q.Order("created_at asc").Find(&sessions).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(sessions) == 0 {
|
||||
@@ -101,7 +153,12 @@ func (h *StatisticsHandler) KPIs(c *gin.Context) {
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c))
|
||||
r, err := parseStatsRange(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c), r)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"})
|
||||
return
|
||||
@@ -141,23 +198,30 @@ func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
period := c.DefaultQuery("period", "day")
|
||||
r, err := parseStatsRange(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
// 是否有自定义区间:有 from/to 时按日粒度;本月预置可按月展示近 6 月
|
||||
hasCustom := strings.TrimSpace(c.Query("from")) != "" && strings.TrimSpace(c.Query("to")) != ""
|
||||
period := strings.TrimSpace(c.DefaultQuery("period", "week"))
|
||||
|
||||
var sessions []model.Session
|
||||
if err := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c)).Find(&sessions).Error; err != nil {
|
||||
q := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c))
|
||||
// 趋势:自定义/今日/本周用区间内数据;本月预置仍看近 6 个月走势
|
||||
if hasCustom || period == "today" || period == "week" || period == "day" {
|
||||
q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To)
|
||||
}
|
||||
if err := q.Find(&sessions).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话趋势失败"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
data := make([]gin.H, 0)
|
||||
if period == "today" {
|
||||
count := 0
|
||||
for _, session := range sessions {
|
||||
if session.CreatedAt.Format("2006-01-02") == now.Format("2006-01-02") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
data = append(data, gin.H{"date": now.Format("01-02"), "count": count})
|
||||
} else if period == "month" {
|
||||
if !hasCustom && period == "month" {
|
||||
// 近 6 个自然月
|
||||
now := time.Now()
|
||||
counts := make(map[string]int)
|
||||
for _, session := range sessions {
|
||||
counts[session.CreatedAt.Format("2006-01")]++
|
||||
@@ -168,14 +232,19 @@ func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
|
||||
data = append(data, gin.H{"date": key, "count": counts[key]})
|
||||
}
|
||||
} else {
|
||||
// 按日:从 From 到 To(不含)
|
||||
counts := make(map[string]int)
|
||||
for _, session := range sessions {
|
||||
counts[session.CreatedAt.Format("01-02")]++
|
||||
counts[session.CreatedAt.Format("2006-01-02")]++
|
||||
}
|
||||
for offset := 6; offset >= 0; offset-- {
|
||||
day := now.AddDate(0, 0, -offset)
|
||||
key := day.Format("01-02")
|
||||
data = append(data, gin.H{"date": key, "count": counts[key]})
|
||||
for d := r.From; d.Before(r.To); d = d.AddDate(0, 0, 1) {
|
||||
key := d.Format("2006-01-02")
|
||||
label := d.Format("01-02")
|
||||
// 跨年区间显示完整日期
|
||||
if r.To.Sub(r.From) > 180*24*time.Hour || d.Year() != time.Now().Year() {
|
||||
label = d.Format("2006-01-02")
|
||||
}
|
||||
data = append(data, gin.H{"date": label, "count": counts[key]})
|
||||
}
|
||||
}
|
||||
middleware.JSON(c, data)
|
||||
@@ -185,7 +254,12 @@ func (h *StatisticsHandler) ResponseDistribution(c *gin.Context) {
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
_, messages, err := loadStatisticsData(middleware.GetTenantID(c))
|
||||
r, err := parseStatsRange(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
_, messages, err := loadStatisticsData(middleware.GetTenantID(c), r)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询响应时长失败"})
|
||||
return
|
||||
@@ -216,8 +290,13 @@ func (h *StatisticsHandler) AgentPerformance(c *gin.Context) {
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
r, err := parseStatsRange(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
sessions, messages, err := loadStatisticsData(tenantID)
|
||||
sessions, messages, err := loadStatisticsData(tenantID, r)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"})
|
||||
return
|
||||
@@ -280,10 +359,19 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
||||
if !requireStatisticsAccess(c) {
|
||||
return
|
||||
}
|
||||
r, err := parseStatsRange(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var sessions []model.Session
|
||||
sessions, _, err := loadStatisticsData(tenantID, r)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||
return
|
||||
}
|
||||
var channels []model.Channel
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&sessions).Error; err != nil {
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ func Migrate(db *gorm.DB) error {
|
||||
&Category{},
|
||||
&KnowledgeEntry{},
|
||||
&QuickReply{},
|
||||
&QuickReplyUserUsage{},
|
||||
&Plan{},
|
||||
&OperationLog{},
|
||||
&Announcement{},
|
||||
|
||||
@@ -201,9 +201,22 @@ type QuickReply struct {
|
||||
GroupName string `gorm:"size:50" json:"group_name"`
|
||||
Status string `gorm:"size:20;default:draft;index" json:"status"` // draft | published
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
// UsageCount 全局累计(管理页展示);排序以个人用量为准见 QuickReplyUserUsage
|
||||
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// MyUsageCount 当前登录用户个人调用次数(接口动态填充,非库字段)
|
||||
MyUsageCount int `gorm:"-" json:"my_usage_count,omitempty"`
|
||||
}
|
||||
|
||||
// QuickReplyUserUsage 坐席个人快捷回复调用统计(按账户独立,非全租户)。
|
||||
type QuickReplyUserUsage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TenantID uint `gorm:"index;not null" json:"tenant_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_qr_user_usage;not null" json:"user_id"`
|
||||
QuickReplyID uint `gorm:"uniqueIndex:idx_qr_user_usage;index;not null" json:"quick_reply_id"`
|
||||
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
|
||||
@@ -2,10 +2,15 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { ConfigProvider } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import App from './App'
|
||||
import { AuthProvider } from './stores/auth'
|
||||
import './index.css'
|
||||
|
||||
// 日历 / DatePicker 月份、星期与 Ant Design 中文一致
|
||||
dayjs.locale('zh-cn')
|
||||
|
||||
const theme = {
|
||||
token: {
|
||||
colorPrimary: '#2563eb',
|
||||
|
||||
@@ -274,11 +274,16 @@ const Dashboard = () => {
|
||||
const [quickKeyword, setQuickKeyword] = useState('')
|
||||
const [quickList, setQuickList] = useState<QuickReply[]>([])
|
||||
const [quickLoading, setQuickLoading] = useState(false)
|
||||
/** 输入框 / 触发建议 */
|
||||
const [slashOpen, setSlashOpen] = useState(false)
|
||||
/** 输入框快捷回复建议:slash=/ 输入码;keyword=正文关键字 */
|
||||
const [suggestOpen, setSuggestOpen] = useState(false)
|
||||
const [suggestMode, setSuggestMode] = useState<'slash' | 'keyword'>('slash')
|
||||
const [slashPrefix, setSlashPrefix] = useState('')
|
||||
const [slashItems, setSlashItems] = useState<QuickReply[]>([])
|
||||
const [slashIndex, setSlashIndex] = useState(0)
|
||||
const [keywordQuery, setKeywordQuery] = useState('')
|
||||
const [suggestItems, setSuggestItems] = useState<QuickReply[]>([])
|
||||
const [suggestIndex, setSuggestIndex] = useState(0)
|
||||
const keywordTimer = useRef<number | null>(null)
|
||||
/** 避免唯一匹配自动填入后立刻再次触发 */
|
||||
const autoAppliedRef = useRef('')
|
||||
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
|
||||
const [noteInput, setNoteInput] = useState('')
|
||||
const [savingNote, setSavingNote] = useState(false)
|
||||
@@ -718,25 +723,75 @@ const Dashboard = () => {
|
||||
}).catch(() => setQuickList([])).finally(() => setQuickLoading(false))
|
||||
}, [quickOpen, quickKeyword])
|
||||
|
||||
const closeSuggest = useCallback(() => {
|
||||
setSuggestOpen(false)
|
||||
setSuggestItems([])
|
||||
setSuggestIndex(0)
|
||||
setSlashPrefix('')
|
||||
setKeywordQuery('')
|
||||
}, [])
|
||||
|
||||
// / 模式:拉输入码建议(按个人调用频次)
|
||||
useEffect(() => {
|
||||
if (!slashOpen) return
|
||||
if (!suggestOpen || suggestMode !== 'slash') return
|
||||
let cancelled = false
|
||||
suggestQuickReplies(slashPrefix).then(res => {
|
||||
suggestQuickReplies({ mode: 'slash', prefix: slashPrefix }).then(res => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(res.data) ? res.data : []
|
||||
setSlashItems(list)
|
||||
setSlashIndex(0)
|
||||
setSuggestItems(list)
|
||||
setSuggestIndex(0)
|
||||
// 输入码细化后仅剩 1 条 → 自动填入(纯 / 不自动,避免误触)
|
||||
if (list.length === 1 && slashPrefix.length > 0) {
|
||||
const only = list[0]
|
||||
const key = `slash:${slashPrefix}:${only.id}`
|
||||
if (autoAppliedRef.current !== key) {
|
||||
autoAppliedRef.current = key
|
||||
// 延迟到下一 tick,避免与 onChange 竞态
|
||||
window.setTimeout(() => {
|
||||
void applyQuickReplyRef.current(only, 'slash')
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!cancelled) setSlashItems([])
|
||||
if (!cancelled) setSuggestItems([])
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [slashOpen, slashPrefix])
|
||||
}, [suggestOpen, suggestMode, slashPrefix])
|
||||
|
||||
const applyQuickReply = useCallback(async (item: QuickReply) => {
|
||||
// 关键字模式:防抖搜索标题/内容
|
||||
useEffect(() => {
|
||||
if (!suggestOpen || suggestMode !== 'keyword') return
|
||||
const q = keywordQuery.trim()
|
||||
if (q.length < 2) {
|
||||
setSuggestItems([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
suggestQuickReplies({ mode: 'keyword', q }).then(res => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(res.data) ? res.data : []
|
||||
setSuggestItems(list)
|
||||
setSuggestIndex(0)
|
||||
}).catch(() => {
|
||||
if (!cancelled) setSuggestItems([])
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [suggestOpen, suggestMode, keywordQuery])
|
||||
|
||||
const applyQuickReplyRef = useRef<(item: QuickReply, mode?: 'slash' | 'keyword' | 'panel') => Promise<void>>(async () => {})
|
||||
|
||||
const applyQuickReply = useCallback(async (item: QuickReply, mode: 'slash' | 'keyword' | 'panel' = 'panel') => {
|
||||
if (mode === 'slash') {
|
||||
// 只替换末尾 /输入码,保留前文
|
||||
setMessageInput(prev => prev.replace(/(^|[\s\n])\/([a-zA-Z0-9_-]*)$/, `$1${item.content}`))
|
||||
} else if (mode === 'keyword') {
|
||||
// 关键字触发:用话术替换当前输入(模板式回复)
|
||||
setMessageInput(item.content)
|
||||
} else {
|
||||
setMessageInput(item.content)
|
||||
}
|
||||
setQuickOpen(false)
|
||||
setSlashOpen(false)
|
||||
setSlashPrefix('')
|
||||
closeSuggest()
|
||||
try {
|
||||
await useQuickReply(item.id)
|
||||
} catch { /* 计数失败可忽略 */ }
|
||||
@@ -744,24 +799,55 @@ const Dashboard = () => {
|
||||
const el = messageInputRef.current
|
||||
if (el) {
|
||||
el.focus()
|
||||
const len = item.content.length
|
||||
const len = el.value.length
|
||||
el.setSelectionRange(len, len)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
}, [closeSuggest])
|
||||
|
||||
/** 从输入内容解析末尾 /shortcut 触发 */
|
||||
const syncSlashFromInput = useCallback((value: string) => {
|
||||
// 匹配末尾未完成的 /xxx(前面是行首或空白)
|
||||
const m = /(^|[\s\n])\/([a-zA-Z0-9_-]*)$/.exec(value)
|
||||
if (m) {
|
||||
setSlashOpen(true)
|
||||
setSlashPrefix(m[2] || '')
|
||||
} else {
|
||||
setSlashOpen(false)
|
||||
setSlashPrefix('')
|
||||
applyQuickReplyRef.current = applyQuickReply
|
||||
|
||||
/** 解析输入:优先 / 输入码;否则关键字联想 */
|
||||
const syncSuggestFromInput = useCallback((value: string) => {
|
||||
// 末尾 /xxx(行首或空白后)
|
||||
const slashMatch = /(^|[\s\n])\/([a-zA-Z0-9_-]*)$/.exec(value)
|
||||
if (slashMatch) {
|
||||
if (keywordTimer.current) {
|
||||
window.clearTimeout(keywordTimer.current)
|
||||
keywordTimer.current = null
|
||||
}
|
||||
}, [])
|
||||
setSuggestMode('slash')
|
||||
setSuggestOpen(true)
|
||||
setSlashPrefix(slashMatch[2] || '')
|
||||
setKeywordQuery('')
|
||||
return
|
||||
}
|
||||
|
||||
// 无 / 时:取最后一段非空白作关键字(至少 2 字)
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
closeSuggest()
|
||||
return
|
||||
}
|
||||
// 取末行最后一词/整段
|
||||
const lastLine = trimmed.split(/\n/).pop() || trimmed
|
||||
const token = lastLine.trim()
|
||||
if (token.length < 2) {
|
||||
if (keywordTimer.current) {
|
||||
window.clearTimeout(keywordTimer.current)
|
||||
keywordTimer.current = null
|
||||
}
|
||||
closeSuggest()
|
||||
return
|
||||
}
|
||||
if (keywordTimer.current) window.clearTimeout(keywordTimer.current)
|
||||
keywordTimer.current = window.setTimeout(() => {
|
||||
setSuggestMode('keyword')
|
||||
setSuggestOpen(true)
|
||||
setKeywordQuery(token)
|
||||
setSlashPrefix('')
|
||||
}, 220)
|
||||
}, [closeSuggest])
|
||||
|
||||
const selected = sessions.find(session => session.id === selectedId)
|
||||
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
||||
@@ -1540,24 +1626,32 @@ const Dashboard = () => {
|
||||
<span className="text-[11px] text-neutral-400 ml-1">输入 / 调用话术</span>
|
||||
</div>
|
||||
<div className="relative flex items-end gap-2.5 px-4 pb-3 pt-1">
|
||||
{slashOpen && (
|
||||
{suggestOpen && (suggestMode === 'slash' || keywordQuery.trim().length >= 2) && (
|
||||
<div className="absolute bottom-full left-4 right-16 mb-1 z-20 max-h-56 overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg">
|
||||
{slashItems.length === 0 ? (
|
||||
<div className="px-3 py-1.5 text-[11px] text-neutral-400 border-b border-neutral-100 flex items-center justify-between">
|
||||
<span>
|
||||
{suggestMode === 'slash'
|
||||
? (slashPrefix ? `输入码 /${slashPrefix}` : '常用话术(按你的调用频率)')
|
||||
: `关键字「${keywordQuery}」`}
|
||||
</span>
|
||||
<span>↑↓ 选择 · Enter 填入 · Esc 关闭</span>
|
||||
</div>
|
||||
{suggestItems.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-neutral-400">无匹配话术</div>
|
||||
) : (
|
||||
slashItems.map((item, idx) => (
|
||||
suggestItems.map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`w-full text-left px-3 py-2 border-0 cursor-pointer ${
|
||||
idx === slashIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
|
||||
idx === suggestIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
|
||||
}`}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
void applyQuickReply(item)
|
||||
void applyQuickReply(item, suggestMode)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium text-neutral-800 truncate">{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded shrink-0">/{item.shortcut}</code>
|
||||
@@ -1565,6 +1659,9 @@ const Dashboard = () => {
|
||||
<span className="text-[10px] text-neutral-400 shrink-0">
|
||||
{item.scope === 'team' ? '团队' : '个人'}
|
||||
</span>
|
||||
{(item.my_usage_count || 0) > 0 && (
|
||||
<span className="text-[10px] text-neutral-400 shrink-0 ml-auto">用过 {item.my_usage_count} 次</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 line-clamp-1 mt-0.5">{item.content}</div>
|
||||
</button>
|
||||
@@ -1576,12 +1673,12 @@ const Dashboard = () => {
|
||||
ref={messageInputRef}
|
||||
rows={3}
|
||||
className="flex-1 min-w-0 min-h-[88px] max-h-[160px] rounded-xl px-3.5 py-3 bg-neutral-50 border border-neutral-200 text-sm leading-6 text-neutral-800 placeholder:text-neutral-400 outline-none resize-y focus:border-[#2563eb] transition-colors"
|
||||
placeholder="支持 Markdown(**加粗** *斜体* 列表 链接)… / 调话术,Enter 发送"
|
||||
placeholder="输入文字联想话术,/ 调输入码… Enter 发送"
|
||||
value={messageInput}
|
||||
onChange={event => {
|
||||
const v = event.target.value
|
||||
setMessageInput(v)
|
||||
syncSlashFromInput(v)
|
||||
syncSuggestFromInput(v)
|
||||
emitTyping()
|
||||
}}
|
||||
onPaste={event => {
|
||||
@@ -1592,30 +1689,30 @@ const Dashboard = () => {
|
||||
}
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (slashOpen && slashItems.length > 0) {
|
||||
if (suggestOpen && suggestItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSlashIndex(i => (i + 1) % slashItems.length)
|
||||
setSuggestIndex(i => (i + 1) % suggestItems.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSlashIndex(i => (i - 1 + slashItems.length) % slashItems.length)
|
||||
setSuggestIndex(i => (i - 1 + suggestItems.length) % suggestItems.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
|
||||
void applyQuickReply(suggestItems[suggestIndex] || suggestItems[0], suggestMode)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setSlashOpen(false)
|
||||
closeSuggest()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
|
||||
void applyQuickReply(suggestItems[suggestIndex] || suggestItems[0], suggestMode)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Spin, message } from 'antd'
|
||||
import { DatePicker, Spin, message } from 'antd'
|
||||
import {
|
||||
ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined,
|
||||
DownloadOutlined, ArrowUpOutlined, ArrowDownOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Column, Line, Pie } from '@ant-design/charts'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
exportStatisticsCSV, getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend,
|
||||
type StatisticsKpis,
|
||||
type StatisticsKpis, type StatsQuery,
|
||||
} from '@/services/api'
|
||||
|
||||
type TimeRange = 'today' | 'week' | 'month'
|
||||
type TimePreset = 'today' | 'week' | 'month' | 'custom'
|
||||
|
||||
const rangeOptions: { value: TimeRange; label: string }[] = [
|
||||
const rangeOptions: { value: TimePreset; label: string }[] = [
|
||||
{ value: 'today', label: '今日' },
|
||||
{ value: 'week', label: '本周' },
|
||||
{ value: 'month', label: '本月' },
|
||||
]
|
||||
|
||||
const trendSubtitle: Record<TimeRange, string> = {
|
||||
today: '今日会话量',
|
||||
week: '近7天会话量变化',
|
||||
month: '近6个月会话量变化',
|
||||
const { RangePicker } = DatePicker
|
||||
|
||||
function buildStatsQuery(preset: TimePreset, custom: [Dayjs, Dayjs] | null): StatsQuery {
|
||||
if (preset === 'custom' && custom?.[0] && custom?.[1]) {
|
||||
return {
|
||||
from: custom[0].format('YYYY-MM-DD'),
|
||||
to: custom[1].format('YYYY-MM-DD'),
|
||||
}
|
||||
}
|
||||
// 本周 = 近7天(含今天),与后端 period=week 一致
|
||||
return { period: preset === 'custom' ? 'week' : preset }
|
||||
}
|
||||
|
||||
const Statistics = () => {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('week')
|
||||
const [timePreset, setTimePreset] = useState<TimePreset>('week')
|
||||
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null)
|
||||
const [kpis, setKpis] = useState<StatisticsKpis | null>(null)
|
||||
const [sessionTrendData, setSessionTrendData] = useState<{ date: string; count: number }[]>([])
|
||||
const [responseDistribution, setResponseDistribution] = useState<{ range: string; count: number }[]>([])
|
||||
@@ -36,17 +45,33 @@ const Statistics = () => {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const statsQuery = useMemo(
|
||||
() => buildStatsQuery(timePreset, customRange),
|
||||
[timePreset, customRange],
|
||||
)
|
||||
|
||||
const trendSubtitle = useMemo(() => {
|
||||
if (timePreset === 'custom' && customRange) {
|
||||
return `${customRange[0].format('YYYY-MM-DD')} ~ ${customRange[1].format('YYYY-MM-DD')} 会话量`
|
||||
}
|
||||
if (timePreset === 'today') return '今日会话量'
|
||||
if (timePreset === 'month') return '近6个月会话量变化'
|
||||
return '近7天会话量变化'
|
||||
}, [timePreset, customRange])
|
||||
|
||||
useEffect(() => {
|
||||
// 自定义未选完区间时不请求
|
||||
if (timePreset === 'custom' && (!customRange?.[0] || !customRange?.[1])) return
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const period = timeRange === 'week' ? 'day' : timeRange
|
||||
const q = statsQuery
|
||||
const [kpiRes, trendRes, distributionRes, channelRes, performanceRes] = await Promise.all([
|
||||
getKPIs(),
|
||||
getSessionTrend(period),
|
||||
getResponseDistribution(),
|
||||
getChannelDistribution(),
|
||||
getAgentPerformance(),
|
||||
getKPIs(q),
|
||||
getSessionTrend(q),
|
||||
getResponseDistribution(q),
|
||||
getChannelDistribution(q),
|
||||
getAgentPerformance(q),
|
||||
])
|
||||
setKpis(kpiRes.data)
|
||||
setSessionTrendData(Array.isArray(trendRes.data) ? trendRes.data : [])
|
||||
@@ -70,8 +95,8 @@ const Statistics = () => {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [timeRange])
|
||||
void load()
|
||||
}, [statsQuery, timePreset, customRange])
|
||||
|
||||
const satPercent = useMemo(() => {
|
||||
const avg = kpis?.satisfaction_avg ?? 0
|
||||
@@ -187,15 +212,18 @@ const Statistics = () => {
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">数据统计</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-neutral-100">
|
||||
{rangeOptions.map(opt => {
|
||||
const active = timeRange === opt.value
|
||||
const active = timePreset === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setTimeRange(opt.value)}
|
||||
onClick={() => {
|
||||
setTimePreset(opt.value)
|
||||
setCustomRange(null)
|
||||
}}
|
||||
className={`h-7 px-3 rounded-md text-sm border-0 cursor-pointer transition-colors ${
|
||||
active
|
||||
? 'bg-[#2563eb] text-white font-medium shadow-sm'
|
||||
@@ -207,6 +235,23 @@ const Statistics = () => {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<RangePicker
|
||||
size="small"
|
||||
allowClear
|
||||
value={customRange}
|
||||
disabledDate={current => current != null && current.isAfter(dayjs().endOf('day'))}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
className={timePreset === 'custom' ? 'ring-1 ring-[#2563eb] rounded-md' : undefined}
|
||||
onChange={values => {
|
||||
if (values?.[0] && values?.[1]) {
|
||||
setCustomRange([values[0], values[1]])
|
||||
setTimePreset('custom')
|
||||
} else {
|
||||
setCustomRange(null)
|
||||
if (timePreset === 'custom') setTimePreset('week')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exporting}
|
||||
@@ -214,7 +259,7 @@ const Statistics = () => {
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportStatisticsCSV()
|
||||
await exportStatisticsCSV(statsQuery)
|
||||
message.success('统计报告已导出')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
@@ -274,7 +319,7 @@ const Statistics = () => {
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-900">会话量趋势</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">{trendSubtitle[timeRange]}</p>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">{trendSubtitle}</p>
|
||||
</div>
|
||||
{sessionTrendData.length === 0 ? (
|
||||
<div className="h-[260px] flex items-center justify-center text-sm text-neutral-400">暂无数据</div>
|
||||
|
||||
+51
-11
@@ -212,6 +212,26 @@ export interface StatisticsKpis {
|
||||
total_messages: number
|
||||
}
|
||||
|
||||
export type StatsQuery = {
|
||||
/** today | week | month */
|
||||
period?: string
|
||||
/** YYYY-MM-DD,与 to 同时传则优先自定义区间 */
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
function statsQueryString(params?: StatsQuery) {
|
||||
const search = new URLSearchParams()
|
||||
if (params?.from && params?.to) {
|
||||
search.set('from', params.from)
|
||||
search.set('to', params.to)
|
||||
} else if (params?.period) {
|
||||
search.set('period', params.period)
|
||||
}
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
|
||||
|
||||
@@ -359,8 +379,8 @@ export const exportSessionsCSV = (params?: {
|
||||
return downloadFile(`/sessions/export${qs ? `?${qs}` : ''}`, `sessions_${Date.now()}.csv`)
|
||||
}
|
||||
|
||||
export const exportStatisticsCSV = () =>
|
||||
downloadFile('/statistics/export', `statistics_${Date.now()}.csv`)
|
||||
export const exportStatisticsCSV = (params?: StatsQuery) =>
|
||||
downloadFile(`/statistics/export${statsQueryString(params)}`, `statistics_${Date.now()}.csv`)
|
||||
|
||||
export const getCustomer = (id: number) =>
|
||||
get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`)
|
||||
@@ -465,6 +485,8 @@ export interface QuickReply {
|
||||
status: 'draft' | 'published' | string
|
||||
sort_order: number
|
||||
usage_count: number
|
||||
/** 当前坐席个人调用次数(建议排序用) */
|
||||
my_usage_count?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -486,11 +508,17 @@ export const getQuickReplies = (params?: {
|
||||
return getList<QuickReply>(`/quick-replies${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export const suggestQuickReplies = (prefix = '') => {
|
||||
/** slash=输入码前缀;keyword=标题/内容关键字。均按个人调用频次排序,最多 10 条 */
|
||||
export const suggestQuickReplies = (opts?: {
|
||||
mode?: 'slash' | 'keyword'
|
||||
prefix?: string
|
||||
q?: string
|
||||
}) => {
|
||||
const search = new URLSearchParams()
|
||||
if (prefix) search.set('prefix', prefix)
|
||||
const qs = search.toString()
|
||||
return get<QuickReply[]>(`/quick-replies/suggest${qs ? `?${qs}` : ''}`)
|
||||
search.set('mode', opts?.mode || 'slash')
|
||||
if (opts?.prefix) search.set('prefix', opts.prefix)
|
||||
if (opts?.q) search.set('q', opts.q)
|
||||
return get<QuickReply[]>(`/quick-replies/suggest?${search.toString()}`)
|
||||
}
|
||||
|
||||
export const createQuickReply = (data: {
|
||||
@@ -597,11 +625,23 @@ export const updateTenantSettings = (
|
||||
) => put<TenantSettings>('/settings', data)
|
||||
|
||||
// Statistics
|
||||
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
|
||||
export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`)
|
||||
export const getResponseDistribution = () => get<{ range: string; count: number }[]>('/statistics/response-distribution')
|
||||
export const getChannelDistribution = () => get<{ type: string; value: number }[]>('/statistics/channels')
|
||||
export const getAgentPerformance = () => get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>('/statistics/performance')
|
||||
export const getKPIs = (params?: StatsQuery) =>
|
||||
get<StatisticsKpis>(`/statistics/kpi${statsQueryString(params)}`)
|
||||
export const getSessionTrend = (params?: StatsQuery | 'today' | 'day' | 'month') => {
|
||||
// 兼容旧调用 getSessionTrend('day')
|
||||
if (typeof params === 'string') {
|
||||
return get<{ date: string; count: number }[]>(`/statistics/trend?period=${params}`)
|
||||
}
|
||||
return get<{ date: string; count: number }[]>(`/statistics/trend${statsQueryString(params)}`)
|
||||
}
|
||||
export const getResponseDistribution = (params?: StatsQuery) =>
|
||||
get<{ range: string; count: number }[]>(`/statistics/response-distribution${statsQueryString(params)}`)
|
||||
export const getChannelDistribution = (params?: StatsQuery) =>
|
||||
get<{ type: string; value: number }[]>(`/statistics/channels${statsQueryString(params)}`)
|
||||
export const getAgentPerformance = (params?: StatsQuery) =>
|
||||
get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>(
|
||||
`/statistics/performance${statsQueryString(params)}`,
|
||||
)
|
||||
|
||||
// Admin
|
||||
export const getTenants = (params?: { search?: string; status?: string; page?: number; pageSize?: number }) => {
|
||||
|
||||
Reference in New Issue
Block a user