diff --git a/server/internal/handler/export.go b/server/internal/handler/export.go index da7cf77..b018fa6 100644 --- a/server/internal/handler/export.go +++ b/server/internal/handler/export.go @@ -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 diff --git a/server/internal/handler/quick_reply.go b/server/internal/handler/quick_reply.go index dc5e9da..510714c 100644 --- a/server/internal/handler/quick_reply.go +++ b/server/internal/handler/quick_reply.go @@ -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, ) - if prefix != "" { - like := prefix + "%" - titleLike := "%" + prefix + "%" - db = db.Where("shortcut LIKE ? OR title LIKE ?", like, titleLike) + switch mode { + case "slash": + if prefix != "" { + // 仅匹配输入码前缀(/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 固定四列(与导入模板一致) diff --git a/server/internal/handler/statistics.go b/server/internal/handler/statistics.go index 1f3c376..7461988 100644 --- a/server/internal/handler/statistics.go +++ b/server/internal/handler/statistics.go @@ -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 } diff --git a/server/internal/model/db.go b/server/internal/model/db.go index 1460338..db2008c 100644 --- a/server/internal/model/db.go +++ b/server/internal/model/db.go @@ -43,6 +43,7 @@ func Migrate(db *gorm.DB) error { &Category{}, &KnowledgeEntry{}, &QuickReply{}, + &QuickReplyUserUsage{}, &Plan{}, &OperationLog{}, &Announcement{}, diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 2878bc9..6903b9f 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -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 int `gorm:"default:0" json:"usage_count"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // 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 { diff --git a/web/src/main.tsx b/web/src/main.tsx index 8c9b84e..accdddd 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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', diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 7679c70..130a034 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -274,11 +274,16 @@ const Dashboard = () => { const [quickKeyword, setQuickKeyword] = useState('') const [quickList, setQuickList] = useState([]) 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([]) - const [slashIndex, setSlashIndex] = useState(0) + const [keywordQuery, setKeywordQuery] = useState('') + const [suggestItems, setSuggestItems] = useState([]) + const [suggestIndex, setSuggestIndex] = useState(0) + const keywordTimer = useRef(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) => { - setMessageInput(item.content) + // 关键字模式:防抖搜索标题/内容 + 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>(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 = () => { 输入 / 调用话术
- {slashOpen && ( + {suggestOpen && (suggestMode === 'slash' || keywordQuery.trim().length >= 2) && (
- {slashItems.length === 0 ? ( +
+ + {suggestMode === 'slash' + ? (slashPrefix ? `输入码 /${slashPrefix}` : '常用话术(按你的调用频率)') + : `关键字「${keywordQuery}」`} + + ↑↓ 选择 · Enter 填入 · Esc 关闭 +
+ {suggestItems.length === 0 ? (
无匹配话术
) : ( - slashItems.map((item, idx) => ( + suggestItems.map((item, idx) => ( @@ -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 } } diff --git a/web/src/pages/agent/Statistics.tsx b/web/src/pages/agent/Statistics.tsx index 79be6e5..db2b41d 100644 --- a/web/src/pages/agent/Statistics.tsx +++ b/web/src/pages/agent/Statistics.tsx @@ -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 = { - 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('week') + const [timePreset, setTimePreset] = useState('week') + const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null) const [kpis, setKpis] = useState(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 = () => {

数据统计

-
+
{rangeOptions.map(opt => { - const active = timeRange === opt.value + const active = timePreset === opt.value return (
+ 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') + } + }} + />