实现对话记录页并对齐工作台三栏布局
完善会话列表筛选与摘要字段、归档接口;对话记录按效果图重构;工作台顶栏等高对齐;默认管理员账号改为 kefu_admin / kefu_admin123。
This commit is contained in:
@@ -19,7 +19,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func seed() {
|
func seed() {
|
||||||
hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
|
// 默认后台管理员:kefu_admin / kefu_admin123(其它种子账号同密码)
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("kefu_admin123"), bcrypt.DefaultCost)
|
||||||
pwd := string(hash)
|
pwd := string(hash)
|
||||||
|
|
||||||
// Plans
|
// Plans
|
||||||
@@ -48,7 +49,7 @@ func seed() {
|
|||||||
|
|
||||||
// Tenant 1 users
|
// Tenant 1 users
|
||||||
model.DB.Create(&model.User{
|
model.DB.Create(&model.User{
|
||||||
TenantID: tenants[0].ID, Role: "admin", Username: "admin", PasswordHash: pwd, Nickname: "赵总(管理员)", Status: "online",
|
TenantID: tenants[0].ID, Role: "admin", Username: "kefu_admin", PasswordHash: pwd, Nickname: "赵总(管理员)", Status: "online",
|
||||||
})
|
})
|
||||||
model.DB.Create(&model.User{
|
model.DB.Create(&model.User{
|
||||||
TenantID: tenants[0].ID, Role: "supervisor", Username: "supervisor", PasswordHash: pwd, Nickname: "客服主管", Status: "online",
|
TenantID: tenants[0].ID, Role: "supervisor", Username: "supervisor", PasswordHash: pwd, Nickname: "客服主管", Status: "online",
|
||||||
@@ -146,6 +147,9 @@ func seed() {
|
|||||||
{Title: "系统维护通知", Content: "平台将于7月20日 02:00-04:00 进行例行维护", Status: "published"},
|
{Title: "系统维护通知", Content: "平台将于7月20日 02:00-04:00 进行例行维护", Status: "published"},
|
||||||
{Title: "新功能上线", Content: "知识库批量导入功能已上线", Status: "published"},
|
{Title: "新功能上线", Content: "知识库批量导入功能已上线", Status: "published"},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
log.Println("默认租户管理员: kefu_admin / kefu_admin123")
|
||||||
|
log.Println("其它种子账号密码均为: kefu_admin123")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func (h *AdminHandler) CreateTenant(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
password := req.AdminPassword
|
password := req.AdminPassword
|
||||||
if password == "" {
|
if password == "" {
|
||||||
password = "password123"
|
password = "kefu_admin123"
|
||||||
}
|
}
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
|||||||
sessions.GET("", session.List)
|
sessions.GET("", session.List)
|
||||||
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("/:id/assign", session.Assign)
|
sessions.POST("/:id/assign", session.Assign)
|
||||||
sessions.POST("/:id/transfer", session.Transfer)
|
sessions.POST("/:id/transfer", session.Transfer)
|
||||||
sessions.POST("/:id/end", session.End)
|
sessions.POST("/:id/end", session.End)
|
||||||
|
sessions.POST("/:id/archive", session.Archive)
|
||||||
sessions.PUT("/:id/priority", session.UpdatePriority)
|
sessions.PUT("/:id/priority", session.UpdatePriority)
|
||||||
sessions.POST("/:id/messages", session.SendMessage)
|
sessions.POST("/:id/messages", session.SendMessage)
|
||||||
sessions.POST("/:id/read", session.MarkRead)
|
sessions.POST("/:id/read", session.MarkRead)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ type SessionListItem struct {
|
|||||||
UnreadCount int `json:"unread_count"`
|
UnreadCount int `json:"unread_count"`
|
||||||
LastMessage string `json:"last_message"`
|
LastMessage string `json:"last_message"`
|
||||||
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
|
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
|
||||||
|
MessageCount int `json:"message_count"`
|
||||||
|
CustomerName string `json:"customer_name"`
|
||||||
|
AgentName string `json:"agent_name"`
|
||||||
|
ChannelName string `json:"channel_name"`
|
||||||
|
ChannelType string `json:"channel_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateNoteReq struct {
|
type CreateNoteReq struct {
|
||||||
@@ -169,33 +174,125 @@ func (h *SessionHandler) List(c *gin.Context) {
|
|||||||
page, pageSize := middleware.GetPageParams(c)
|
page, pageSize := middleware.GetPageParams(c)
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
priority := c.Query("priority")
|
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")
|
||||||
|
|
||||||
var sessions []model.Session
|
var sessions []model.Session
|
||||||
var total int64
|
var total int64
|
||||||
|
|
||||||
query := model.DB.Where("tenant_id = ?", tenantID)
|
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID)
|
||||||
if middleware.GetRole(c) == "agent" {
|
if middleware.GetRole(c) == "agent" {
|
||||||
query = query.Where("agent_id = ? OR status = ?", middleware.GetUserID(c), "waiting")
|
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
|
||||||
}
|
}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
query = query.Where("status = ?", status)
|
query = query.Where("sessions.status = ?", status)
|
||||||
}
|
}
|
||||||
if priority != "" {
|
if priority != "" {
|
||||||
query = query.Where("priority = ?", 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
query.Model(&model.Session{}).Count(&total)
|
query.Count(&total)
|
||||||
if err := query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
|
if err := query.Order("sessions.created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话失败"})
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]SessionListItem, 0, len(sessions))
|
items := make([]SessionListItem, 0, len(sessions))
|
||||||
for _, session := range sessions {
|
for _, session := range sessions {
|
||||||
item := SessionListItem{Session: session}
|
item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]}
|
||||||
if middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) {
|
if middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) {
|
||||||
item.UnreadCount = unreadCount(session)
|
item.UnreadCount = unreadCount(session)
|
||||||
}
|
}
|
||||||
|
if cu, ok := customerMap[session.CustomerID]; ok {
|
||||||
|
item.CustomerName = cu.Name
|
||||||
|
}
|
||||||
|
if session.AgentID != nil {
|
||||||
|
if u, ok := agentMap[*session.AgentID]; ok {
|
||||||
|
item.AgentName = u.Nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ch, ok := channelMap[session.ChannelID]; ok {
|
||||||
|
item.ChannelName = ch.Name
|
||||||
|
item.ChannelType = ch.Type
|
||||||
|
}
|
||||||
var lastMsg model.Message
|
var lastMsg model.Message
|
||||||
if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil {
|
if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil {
|
||||||
if lastMsg.Type == "image" {
|
if lastMsg.Type == "image" {
|
||||||
@@ -216,6 +313,22 @@ func (h *SessionHandler) List(c *gin.Context) {
|
|||||||
middleware.JSONList(c, items, total, page, pageSize)
|
middleware.JSONList(c, items, total, page, pageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func uniqueUint(ids []uint) []uint {
|
||||||
|
seen := make(map[uint]struct{}, len(ids))
|
||||||
|
out := make([]uint, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
out = append(out, id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SessionHandler) Get(c *gin.Context) {
|
func (h *SessionHandler) Get(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
|
||||||
@@ -299,10 +412,14 @@ func (h *SessionHandler) ListAvailableAgents(c *gin.Context) {
|
|||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
// all=1 返回租户全部坐席(含离线),用于对话记录筛选;默认仅在线(工作台转接)
|
||||||
|
q := model.DB.Where("tenant_id = ? AND role IN ?", middleware.GetTenantID(c), []string{"agent", "supervisor", "admin"})
|
||||||
|
if c.Query("all") != "1" {
|
||||||
|
q = q.Where("role = ? AND status = ?", "agent", "online")
|
||||||
|
}
|
||||||
var users []model.User
|
var users []model.User
|
||||||
if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", middleware.GetTenantID(c), "agent", "online").
|
if err := q.Order("nickname asc").Find(&users).Error; err != nil {
|
||||||
Order("nickname asc").Find(&users).Error; err != nil {
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
items := make([]agentItem, 0, len(users))
|
items := make([]agentItem, 0, len(users))
|
||||||
@@ -523,3 +640,71 @@ func (h *SessionHandler) UpdatePriority(c *gin.Context) {
|
|||||||
|
|
||||||
middleware.JSON(c, gin.H{"message": "已更新"})
|
middleware.JSON(c, gin.H{"message": "已更新"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Archive 将已结束会话归档(主管/管理员)
|
||||||
|
func (h *SessionHandler) Archive(c *gin.Context) {
|
||||||
|
if !isTenantManager(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, ok := loadTenantSession(c, c.Param("id"))
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if session.Status != "ended" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅已结束的会话可归档"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := model.DB.Model(&model.Session{}).
|
||||||
|
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "ended").
|
||||||
|
Update("status", "archived")
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "归档失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model.DB.Create(&model.SessionEvent{
|
||||||
|
SessionID: session.ID,
|
||||||
|
OperatorID: middleware.GetUserID(c),
|
||||||
|
Action: "archive",
|
||||||
|
Detail: "会话已归档",
|
||||||
|
})
|
||||||
|
session.Status = "archived"
|
||||||
|
broadcastSessionUpdate(session)
|
||||||
|
middleware.JSON(c, gin.H{"message": "已归档", "session": session})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchArchive 批量归档已结束会话
|
||||||
|
func (h *SessionHandler) BatchArchive(c *gin.Context) {
|
||||||
|
if !isTenantManager(c) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
IDs []uint `json:"ids" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择要归档的会话"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.IDs) > 100 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "单次最多归档 100 条"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tenantID := middleware.GetTenantID(c)
|
||||||
|
result := model.DB.Model(&model.Session{}).
|
||||||
|
Where("tenant_id = ? AND status = ? AND id IN ?", tenantID, "ended", req.IDs).
|
||||||
|
Update("status", "archived")
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量归档失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range req.IDs {
|
||||||
|
model.DB.Create(&model.SessionEvent{
|
||||||
|
SessionID: id,
|
||||||
|
OperatorID: middleware.GetUserID(c),
|
||||||
|
Action: "archive",
|
||||||
|
Detail: "会话已归档",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
middleware.JSON(c, gin.H{"message": "已归档", "count": result.RowsAffected})
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Form form={form} layout="vertical" onFinish={onFinish} autoComplete="off">
|
<Form form={form} layout="vertical" onFinish={onFinish} autoComplete="off">
|
||||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
<Input placeholder="admin / agent1" size="large" />
|
<Input placeholder="kefu_admin" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
<Input.Password placeholder="password123" size="large" />
|
<Input.Password placeholder="kefu_admin123" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
||||||
|
|||||||
+644
-182
@@ -1,48 +1,185 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo, useCallback, type MouseEvent } from 'react'
|
||||||
import { Input, Select, Tag, Empty, Spin } from 'antd'
|
import { Empty, Spin, Select, DatePicker, message, Pagination, Checkbox } from 'antd'
|
||||||
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
|
|
||||||
import {
|
import {
|
||||||
getCustomers, getSession, getSessions,
|
SearchOutlined, DownloadOutlined, InboxOutlined, CloseOutlined,
|
||||||
type Customer, type Message, type Session, type SessionEvent,
|
UserOutlined, ClockCircleOutlined, MessageOutlined, FieldTimeOutlined,
|
||||||
|
StarFilled, StarOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
|
import {
|
||||||
|
archiveSession, batchArchiveSessions, getAvailableAgents, getChannels, getSession, getSessions,
|
||||||
|
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'
|
||||||
|
import { useAuth } from '@/stores/auth'
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const { RangePicker } = DatePicker
|
||||||
active: 'blue', waiting: 'orange', ended: 'green', archived: 'default',
|
|
||||||
|
const statusMeta: Record<string, { label: string; bg: string; color: string }> = {
|
||||||
|
waiting: { label: '等待中', bg: '#fffbeb', color: '#d97706' },
|
||||||
|
active: { label: '进行中', bg: '#dbeafe', color: '#2563eb' },
|
||||||
|
ended: { label: '已结束', bg: '#f1f5f9', color: '#64748b' },
|
||||||
|
archived: { label: '已归档', bg: '#f1f5f9', color: '#64748b' },
|
||||||
}
|
}
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
active: '进行中', ended: '已结束', waiting: '等待中', archived: '已归档',
|
const endReasonMeta: Record<string, { label: string; bg: string; color: string }> = {
|
||||||
|
resolved: { label: '已解决', bg: '#f0fdf4', color: '#16a34a' },
|
||||||
|
no_response: { label: '无人回复', bg: '#fffbeb', color: '#d97706' },
|
||||||
|
visitor_left: { label: '访客离开', bg: '#f1f5f9', color: '#64748b' },
|
||||||
|
transferred: { label: '已转接', bg: '#ecfeff', color: '#0891b2' },
|
||||||
|
other: { label: '其他', bg: '#f1f5f9', color: '#64748b' },
|
||||||
}
|
}
|
||||||
const priorityLabels: Record<string, string> = { urgent: '紧急', normal: '普通' }
|
|
||||||
|
const channelStyle: Record<string, { bg: string; color: string; label: string }> = {
|
||||||
|
web: { bg: '#ecfeff', color: '#0891b2', label: '网页' },
|
||||||
|
website: { bg: '#ecfeff', color: '#0891b2', label: '网页' },
|
||||||
|
wechat: { bg: '#eff6ff', color: '#2563eb', label: '微信' },
|
||||||
|
app: { bg: '#f0fdf4', color: '#16a34a', label: 'APP' },
|
||||||
|
phone: { bg: '#fffbeb', color: '#d97706', label: '电话' },
|
||||||
|
widget: { bg: '#ecfeff', color: '#0891b2', label: '网页' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarPalettes = [
|
||||||
|
{ bg: '#dbeafe', color: '#2563eb' },
|
||||||
|
{ bg: '#ecfeff', color: '#0891b2' },
|
||||||
|
{ bg: '#fef2f2', color: '#dc2626' },
|
||||||
|
{ bg: '#fffbeb', color: '#d97706' },
|
||||||
|
{ bg: '#f0fdf4', color: '#16a34a' },
|
||||||
|
{ bg: '#f3e8ff', color: '#7c3aed' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function avatarPalette(key: string) {
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < key.length; i++) h = key.charCodeAt(i) + ((h << 5) - h)
|
||||||
|
return avatarPalettes[Math.abs(h) % avatarPalettes.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(start?: string | null, end?: string | null, status?: string) {
|
||||||
|
if (!start) return '—'
|
||||||
|
if (!end && (status === 'active' || status === 'waiting')) return '进行中'
|
||||||
|
const endMs = end ? new Date(end).getTime() : Date.now()
|
||||||
|
const mins = Math.max(1, Math.round((endMs - new Date(start).getTime()) / 60000))
|
||||||
|
if (mins < 60) return `${mins}分钟`
|
||||||
|
const h = Math.floor(mins / 60)
|
||||||
|
const m = mins % 60
|
||||||
|
return m ? `${h}小时${m}分` : `${h}小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRange(start?: string | null, end?: string | null) {
|
||||||
|
if (!start) return '—'
|
||||||
|
const s = dayjs(start)
|
||||||
|
if (!end) return `${s.format('MM-DD HH:mm')} 开始`
|
||||||
|
const e = dayjs(end)
|
||||||
|
if (s.isSame(e, 'day')) return `${s.format('MM-DD HH:mm')} ~ ${e.format('HH:mm')}`
|
||||||
|
return `${s.format('MM-DD HH:mm')} ~ ${e.format('MM-DD HH:mm')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso?: string | null) {
|
||||||
|
if (!iso) return ''
|
||||||
|
return dayjs(iso).format('HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso?: string | null) {
|
||||||
|
if (!iso) return '—'
|
||||||
|
return dayjs(iso).format('YYYY-MM-DD HH:mm')
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelLabel(s: Session) {
|
||||||
|
const t = (s.channel_type || '').toLowerCase()
|
||||||
|
if (channelStyle[t]) return channelStyle[t]
|
||||||
|
if (s.channel_name) return { bg: '#f1f5f9', color: '#64748b', label: s.channel_name }
|
||||||
|
return { bg: '#ecfeff', color: '#0891b2', label: '网页' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function satisfactionTone(score: number | null | undefined): 'good' | 'mid' | 'bad' | 'none' {
|
||||||
|
if (score == null || score <= 0) return 'none'
|
||||||
|
if (score >= 4) return 'good'
|
||||||
|
if (score >= 3) return 'mid'
|
||||||
|
return 'bad'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MoodBadge = ({ score }: { score: number | null | undefined }) => {
|
||||||
|
const tone = satisfactionTone(score)
|
||||||
|
if (tone === 'none') return null
|
||||||
|
const bg = tone === 'good' ? '#16a34a' : tone === 'mid' ? '#94a3b8' : '#dc2626'
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="absolute -top-1 -right-1 w-4 h-4 rounded-full flex items-center justify-center text-[9px] text-white shadow-[0_0_0_2px_#fff]"
|
||||||
|
style={{ backgroundColor: bg }}
|
||||||
|
title={score ? `满意度 ${score} 分` : ''}
|
||||||
|
>
|
||||||
|
{tone === 'good' ? '☺' : tone === 'bad' ? '☹' : '·'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Stars = ({ score, size = 12 }: { score: number | null | undefined; size?: number }) => {
|
||||||
|
const n = score && score > 0 ? Math.min(5, score) : 0
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) =>
|
||||||
|
i < n
|
||||||
|
? <StarFilled key={i} style={{ fontSize: size, color: '#d97706' }} />
|
||||||
|
: <StarOutlined key={i} style={{ fontSize: size, color: '#e2e8f0' }} />,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TagChip = ({ label, bg, color }: { label: string; bg: string; color: string }) => (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded px-1.5 py-0.5 text-[11px] whitespace-nowrap font-medium"
|
||||||
|
style={{ backgroundColor: bg, color }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
const ChatHistory = () => {
|
const ChatHistory = () => {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const canArchive = user?.role === 'admin' || user?.role === 'supervisor'
|
||||||
|
|
||||||
const [sessions, setSessions] = useState<Session[]>([])
|
const [sessions, setSessions] = useState<Session[]>([])
|
||||||
const [customers, setCustomers] = useState<Record<number, Customer>>({})
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const [searchInput, setSearchInput] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState<string>()
|
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
|
||||||
const [priorityFilter, setPriorityFilter] = useState<string>()
|
const [statusFilter, setStatusFilter] = useState<string | undefined>()
|
||||||
|
const [agentFilter, setAgentFilter] = useState<number | undefined>()
|
||||||
|
const [channelFilter, setChannelFilter] = useState<number | undefined>()
|
||||||
|
|
||||||
|
const [agents, setAgents] = useState<AvailableAgent[]>([])
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([])
|
||||||
|
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||||
const [messages, setMessages] = useState<Message[]>([])
|
const [messages, setMessages] = useState<Message[]>([])
|
||||||
const [events, setEvents] = useState<SessionEvent[]>([])
|
const [events, setEvents] = useState<SessionEvent[]>([])
|
||||||
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
|
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
|
const [checkedIds, setCheckedIds] = useState<number[]>([])
|
||||||
|
const [archiving, setArchiving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const loadSessions = useCallback(async () => {
|
||||||
loadSessions()
|
|
||||||
}, [statusFilter, priorityFilter])
|
|
||||||
|
|
||||||
const loadSessions = async () => {
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [sessionRes, customerRes] = await Promise.all([
|
const res = await getSessions({
|
||||||
getSessions({ status: statusFilter, priority: priorityFilter, page: 1, pageSize: 100 }),
|
status: statusFilter,
|
||||||
getCustomers({ page: 1, pageSize: 200 }),
|
agent_id: agentFilter,
|
||||||
])
|
channel_id: channelFilter,
|
||||||
const list = Array.isArray(sessionRes.list) ? sessionRes.list : []
|
search: search || undefined,
|
||||||
|
from: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||||
|
to: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
})
|
||||||
|
const list = Array.isArray(res.list) ? res.list : []
|
||||||
setSessions(list)
|
setSessions(list)
|
||||||
const map = Object.fromEntries((customerRes.list || []).map(c => [c.id, c]))
|
setTotal(res.total || 0)
|
||||||
setCustomers(map)
|
setCheckedIds([])
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
const still = selectedId && list.some(s => s.id === selectedId)
|
const still = selectedId && list.some(s => s.id === selectedId)
|
||||||
if (!still) setSelectedId(list[0].id)
|
if (!still) setSelectedId(list[0].id)
|
||||||
@@ -51,10 +188,25 @@ const ChatHistory = () => {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setSessions([])
|
setSessions([])
|
||||||
|
setTotal(0)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [statusFilter, agentFilter, channelFilter, search, dateRange, page, pageSize, selectedId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSessions()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- 选中项变化不重拉列表
|
||||||
|
}, [statusFilter, agentFilter, channelFilter, search, dateRange, page, pageSize])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getAvailableAgents(false).then(res => {
|
||||||
|
setAgents(Array.isArray(res.data) ? res.data : [])
|
||||||
|
}).catch(() => setAgents([]))
|
||||||
|
getChannels().then(res => {
|
||||||
|
setChannels(Array.isArray(res.data) ? res.data : [])
|
||||||
|
}).catch(() => setChannels([]))
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedId) {
|
if (!selectedId) {
|
||||||
@@ -65,191 +217,501 @@ const ChatHistory = () => {
|
|||||||
}
|
}
|
||||||
setDetailLoading(true)
|
setDetailLoading(true)
|
||||||
getSession(selectedId).then(res => {
|
getSession(selectedId).then(res => {
|
||||||
setMessages(res.data.messages || [])
|
const msgs = res.data.messages || []
|
||||||
|
setMessages(msgs)
|
||||||
setEvents(res.data.events || [])
|
setEvents(res.data.events || [])
|
||||||
setSelectedSession(res.data.session || sessions.find(s => s.id === selectedId) || null)
|
const base = sessions.find(s => s.id === selectedId)
|
||||||
|
const detail = res.data.session
|
||||||
|
const merged = detail
|
||||||
|
? {
|
||||||
|
...detail,
|
||||||
|
customer_name: base?.customer_name || detail.customer_name,
|
||||||
|
agent_name: base?.agent_name || detail.agent_name,
|
||||||
|
channel_name: base?.channel_name || detail.channel_name,
|
||||||
|
channel_type: base?.channel_type || detail.channel_type,
|
||||||
|
message_count: msgs.length,
|
||||||
|
last_message: base?.last_message || detail.last_message,
|
||||||
|
}
|
||||||
|
: base || null
|
||||||
|
setSelectedSession(merged)
|
||||||
|
// 回写列表消息数,避免「0条消息」与详情不一致
|
||||||
|
if (msgs.length > 0) {
|
||||||
|
setSessions(prev => prev.map(s =>
|
||||||
|
s.id === selectedId ? { ...s, message_count: msgs.length } : s,
|
||||||
|
))
|
||||||
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setMessages([])
|
setMessages([])
|
||||||
setEvents([])
|
setEvents([])
|
||||||
}).finally(() => setDetailLoading(false))
|
}).finally(() => setDetailLoading(false))
|
||||||
}, [selectedId])
|
}, [selectedId])
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const selected = selectedSession || sessions.find(s => s.id === selectedId) || null
|
||||||
const keyword = search.trim().toLowerCase()
|
const customerName = selected?.customer_name || (selected ? `客户${selected.customer_id}` : '')
|
||||||
return sessions.filter(s => {
|
|
||||||
if (!keyword) return true
|
|
||||||
const name = customers[s.customer_id]?.name || ''
|
|
||||||
return name.toLowerCase().includes(keyword)
|
|
||||||
|| String(s.id).includes(keyword)
|
|
||||||
|| String(s.customer_id).includes(keyword)
|
|
||||||
|| (s.last_message || '').toLowerCase().includes(keyword)
|
|
||||||
})
|
|
||||||
}, [sessions, customers, search])
|
|
||||||
|
|
||||||
const selected = selectedSession || sessions.find(s => s.id === selectedId)
|
const agentNameById = useMemo(() => {
|
||||||
const customer = selected ? customers[selected.customer_id] : null
|
const map = new Map<number, string>()
|
||||||
|
agents.forEach(a => map.set(a.id, a.nickname))
|
||||||
|
return map
|
||||||
|
}, [agents])
|
||||||
|
|
||||||
|
const resolveAgentName = (s?: Session | null) => {
|
||||||
|
if (!s) return '未分配'
|
||||||
|
if (s.agent_name) return s.agent_name
|
||||||
|
if (s.agent_id && agentNameById.has(s.agent_id)) return agentNameById.get(s.agent_id)!
|
||||||
|
if (s.agent_id) return `客服#${s.agent_id}`
|
||||||
|
return '未分配'
|
||||||
|
}
|
||||||
|
const agentName = resolveAgentName(selected)
|
||||||
|
const msgCount = messages.length > 0 ? messages.length : (selected?.message_count ?? 0)
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPage(1)
|
||||||
|
setSearch(searchInput.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCheck = (id: number, e: MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setCheckedIds(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBatchArchive = async () => {
|
||||||
|
if (!canArchive) {
|
||||||
|
message.warning('仅主管或管理员可归档')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ids = checkedIds.length > 0
|
||||||
|
? checkedIds
|
||||||
|
: sessions.filter(s => s.status === 'ended').map(s => s.id)
|
||||||
|
const ended = ids.filter(id => sessions.find(s => s.id === id)?.status === 'ended')
|
||||||
|
if (ended.length === 0) {
|
||||||
|
message.info('请先勾选已结束的会话')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setArchiving(true)
|
||||||
|
try {
|
||||||
|
const res = await batchArchiveSessions(ended)
|
||||||
|
message.success(`已归档 ${res.data.count} 条会话`)
|
||||||
|
setCheckedIds([])
|
||||||
|
await loadSessions()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '归档失败')
|
||||||
|
} finally {
|
||||||
|
setArchiving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleArchiveOne = async (id: number) => {
|
||||||
|
if (!canArchive) return
|
||||||
|
try {
|
||||||
|
await archiveSession(id)
|
||||||
|
message.success('已归档')
|
||||||
|
await loadSessions()
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '归档失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timelineEvents = useMemo(() => {
|
||||||
|
const actionLabel: Record<string, string> = {
|
||||||
|
assign: '接入会话',
|
||||||
|
transfer: '转接会话',
|
||||||
|
end: '结束会话',
|
||||||
|
note: '添加备注',
|
||||||
|
archive: '归档会话',
|
||||||
|
priority: '调整优先级',
|
||||||
|
}
|
||||||
|
return events.map(ev => ({
|
||||||
|
...ev,
|
||||||
|
label: actionLabel[ev.action] || ev.action,
|
||||||
|
}))
|
||||||
|
}, [events])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex">
|
<div className="h-full flex flex-col min-h-0 bg-neutral-50 overflow-hidden">
|
||||||
<div className="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col">
|
{/* 顶栏 — 全宽 */}
|
||||||
<div className="p-3 border-b border-neutral-100 space-y-2">
|
<header className="shrink-0 h-14 px-6 flex items-center justify-between bg-white border-b border-neutral-200">
|
||||||
<Input
|
<h1 className="text-lg font-semibold text-neutral-900 m-0">对话记录</h1>
|
||||||
prefix={<SearchOutlined />}
|
<div className="flex items-center gap-2">
|
||||||
placeholder="搜索客户/会话/消息"
|
<button
|
||||||
value={search}
|
type="button"
|
||||||
onChange={e => setSearch(e.target.value)}
|
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"
|
||||||
allowClear
|
onClick={() => message.info('导出功能后续版本提供')}
|
||||||
size="small"
|
>
|
||||||
/>
|
<DownloadOutlined />
|
||||||
<div className="flex gap-2">
|
导出
|
||||||
<Select
|
</button>
|
||||||
placeholder="状态"
|
<button
|
||||||
value={statusFilter}
|
type="button"
|
||||||
onChange={setStatusFilter}
|
disabled={archiving}
|
||||||
allowClear
|
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"
|
||||||
size="small"
|
onClick={handleBatchArchive}
|
||||||
className="flex-1"
|
>
|
||||||
options={Object.entries(statusLabels).map(([k, v]) => ({ value: k, label: v }))}
|
<InboxOutlined />
|
||||||
/>
|
批量归档
|
||||||
<Select
|
</button>
|
||||||
placeholder="优先级"
|
</div>
|
||||||
value={priorityFilter}
|
</header>
|
||||||
onChange={setPriorityFilter}
|
|
||||||
allowClear
|
{/* 筛选栏 — 全宽,左右分栏顶边对齐 */}
|
||||||
size="small"
|
<div className="shrink-0 px-6 py-3 bg-white border-b border-neutral-200">
|
||||||
className="flex-1"
|
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||||
options={Object.entries(priorityLabels).map(([k, v]) => ({ value: k, label: v }))}
|
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-xl h-8 px-3 rounded-md border border-neutral-200 bg-white">
|
||||||
|
<SearchOutlined className="text-neutral-400 text-sm shrink-0" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索客户名称、会话内容..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleSearch() }}
|
||||||
|
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-neutral-800 placeholder:text-neutral-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<RangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={v => { setDateRange(v); setPage(1) }}
|
||||||
|
className="!h-8"
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSearch}
|
||||||
|
className="h-8 px-4 rounded-lg text-sm font-medium bg-[#2563eb] text-white border-0 cursor-pointer hover:bg-[#1d4ed8] shrink-0"
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{loading ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="flex justify-center py-10"><Spin /></div>
|
<span className="text-sm text-neutral-500 whitespace-nowrap">渠道:</span>
|
||||||
) : filtered.map(s => {
|
<Select
|
||||||
const name = customers[s.customer_id]?.name || `客户${s.customer_id}`
|
allowClear
|
||||||
return (
|
placeholder="全部"
|
||||||
<button
|
value={channelFilter}
|
||||||
key={s.id}
|
onChange={v => { setChannelFilter(v); setPage(1) }}
|
||||||
type="button"
|
className="!w-[120px]"
|
||||||
className={`w-full text-left px-3 py-3 border-b border-neutral-50 hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
|
size="small"
|
||||||
onClick={() => setSelectedId(s.id)}
|
options={channels.map(ch => ({ value: ch.id, label: ch.name || ch.type }))}
|
||||||
>
|
/>
|
||||||
<div className="flex items-center justify-between gap-2">
|
</div>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="w-8 h-8 rounded-full bg-neutral-100 flex items-center justify-center flex-shrink-0 text-xs font-semibold text-neutral-500">
|
<span className="text-sm text-neutral-500 whitespace-nowrap">客服:</span>
|
||||||
{name.slice(0, 1)}
|
<Select
|
||||||
</div>
|
allowClear
|
||||||
<div className="min-w-0">
|
placeholder="全部"
|
||||||
<div className="text-sm font-medium text-neutral-800 truncate">{name}</div>
|
value={agentFilter}
|
||||||
<div className="text-xs text-neutral-400 truncate">{s.last_message || `会话 #${s.id}`}</div>
|
onChange={v => { setAgentFilter(v); setPage(1) }}
|
||||||
</div>
|
className="!w-[120px]"
|
||||||
</div>
|
size="small"
|
||||||
<Tag color={statusColors[s.status]} className="text-xs m-0 shrink-0">{statusLabels[s.status] || s.status}</Tag>
|
options={agents.map(a => ({ value: a.id, label: a.nickname }))}
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center justify-between mt-1.5 pl-10">
|
</div>
|
||||||
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleString('zh-CN')}</span>
|
<div className="flex items-center gap-1.5">
|
||||||
{s.priority === 'urgent' && <Tag color="red" className="text-xs m-0">紧急</Tag>}
|
<span className="text-sm text-neutral-500 whitespace-nowrap">状态:</span>
|
||||||
{s.satisfaction_score ? <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span> : null}
|
<Select
|
||||||
</div>
|
allowClear
|
||||||
</button>
|
placeholder="全部"
|
||||||
)
|
value={statusFilter}
|
||||||
})}
|
onChange={v => { setStatusFilter(v); setPage(1) }}
|
||||||
{!loading && filtered.length === 0 && (
|
className="!w-[120px]"
|
||||||
<div className="flex items-center justify-center h-40"><Empty description="暂无对话记录" /></div>
|
size="small"
|
||||||
)}
|
options={[
|
||||||
|
{ value: 'active', label: '进行中' },
|
||||||
|
{ value: 'waiting', label: '等待中' },
|
||||||
|
{ value: 'ended', label: '已结束' },
|
||||||
|
{ value: 'archived', label: '已归档' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto text-sm text-neutral-400 whitespace-nowrap">
|
||||||
|
共 {total} 条记录
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 bg-neutral-50 overflow-auto">
|
{/* 列表 | 详情 — 顶边同一基线 */}
|
||||||
{selected ? (
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<div className="p-6 max-w-3xl mx-auto">
|
{/* 会话列表 */}
|
||||||
<div className="bg-white rounded-xl border border-neutral-200 p-5 mb-4">
|
<div className="flex-1 min-w-0 flex flex-col overflow-hidden border-r border-neutral-200">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex-1 overflow-y-auto px-6 py-4 bg-neutral-50">
|
||||||
<div className="min-w-0">
|
{loading ? (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex justify-center py-20"><Spin size="large" /></div>
|
||||||
<h3 className="text-lg font-semibold text-neutral-800 m-0 truncate">
|
) : sessions.length === 0 ? (
|
||||||
{customer?.name || `客户${selected.customer_id}`}
|
<div className="flex items-center justify-center h-48">
|
||||||
</h3>
|
<Empty description="暂无对话记录" />
|
||||||
<Tag color={statusColors[selected.status]}>{statusLabels[selected.status] || selected.status}</Tag>
|
|
||||||
{selected.priority === 'urgent' && <Tag color="red">紧急</Tag>}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-neutral-400 mt-1">
|
|
||||||
会话 #{selected.id}
|
|
||||||
{customer?.source ? ` · ${customer.source}` : ''}
|
|
||||||
{' · '}
|
|
||||||
{new Date(selected.created_at).toLocaleString('zh-CN')}
|
|
||||||
{selected.ended_at ? ` ~ ${new Date(selected.ended_at).toLocaleString('zh-CN')}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selected.satisfaction_score != null && (
|
|
||||||
<div className="text-right shrink-0">
|
|
||||||
<div className="text-xl text-yellow-500">
|
|
||||||
{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(Math.max(0, 5 - selected.satisfaction_score))}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-neutral-400">满意度评分</div>
|
|
||||||
{selected.satisfaction_text && (
|
|
||||||
<div className="text-xs text-neutral-500 mt-1 max-w-[180px]">{selected.satisfaction_text}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
<div className="bg-white rounded-xl border border-neutral-200 p-5 mb-4">
|
{sessions.map(s => {
|
||||||
<div className="text-sm font-medium text-neutral-700 mb-4">消息记录</div>
|
const name = s.customer_name || `客户${s.customer_id}`
|
||||||
<div className="space-y-4">
|
const pal = avatarPalette(name)
|
||||||
{detailLoading ? (
|
const ch = channelLabel(s)
|
||||||
<div className="flex justify-center py-10"><Spin /></div>
|
const st = statusMeta[s.status] || statusMeta.ended
|
||||||
) : messages.length === 0 ? (
|
const reason = s.end_reason ? endReasonMeta[s.end_reason] : null
|
||||||
<Empty description="暂无消息记录" />
|
const active = selectedId === s.id
|
||||||
) : messages.map(message => {
|
const checked = checkedIds.includes(s.id)
|
||||||
const isAgent = message.sender_type === 'agent'
|
const displayMsgCount = selectedId === s.id && messages.length > 0
|
||||||
|
? messages.length
|
||||||
|
: (s.message_count ?? 0)
|
||||||
return (
|
return (
|
||||||
<div key={message.id} className={`flex ${isAgent ? 'justify-end' : 'justify-start'}`}>
|
<div
|
||||||
<div className={`max-w-[70%] rounded-xl px-3.5 py-2.5 text-sm ${
|
key={s.id}
|
||||||
isAgent ? 'bg-[#2563eb] text-white rounded-tr-sm' : 'bg-neutral-100 text-neutral-700 rounded-tl-sm'
|
role="button"
|
||||||
}`}>
|
tabIndex={0}
|
||||||
<div className={`text-xs mb-1 ${isAgent ? 'text-white/70' : 'text-neutral-400'}`}>
|
onClick={() => setSelectedId(s.id)}
|
||||||
{isAgent ? '客服' : '访客'}
|
onKeyDown={e => { if (e.key === 'Enter') setSelectedId(s.id) }}
|
||||||
|
className={`rounded-lg p-4 cursor-pointer bg-white transition-shadow ${
|
||||||
|
active
|
||||||
|
? 'border-2 border-[#2563eb] shadow-md'
|
||||||
|
: 'border border-neutral-200 shadow-sm hover:border-blue-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex items-start gap-2 shrink-0">
|
||||||
|
{canArchive && s.status === 'ended' && (
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onClick={e => toggleCheck(s.id, e as unknown as MouseEvent)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
|
||||||
|
style={{ backgroundColor: pal.bg, color: pal.color }}
|
||||||
|
>
|
||||||
|
{name.slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
<MoodBadge score={s.satisfaction_score} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{message.type === 'image' ? (
|
|
||||||
<ChatImage src={message.content} alt="图片" className="max-w-56 max-h-56" />
|
<div className="flex-1 min-w-0">
|
||||||
) : (
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<div className="whitespace-pre-wrap break-words">{message.content}</div>
|
<span className="font-medium text-neutral-900 truncate">{name}</span>
|
||||||
)}
|
<TagChip label={ch.label} bg={ch.bg} color={ch.color} />
|
||||||
<div className={`text-xs mt-1 ${isAgent ? 'text-white/60' : 'text-neutral-400'}`}>
|
{reason && <TagChip label={reason.label} bg={reason.bg} color={reason.color} />}
|
||||||
{new Date(message.sent_at).toLocaleString('zh-CN')}
|
{s.priority === 'urgent' && (
|
||||||
|
<TagChip label="紧急" bg="#fef2f2" color="#dc2626" />
|
||||||
|
)}
|
||||||
|
<span className="ml-auto">
|
||||||
|
<TagChip label={st.label} bg={st.bg} color={st.color} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="truncate mb-2 text-sm text-neutral-500 m-0">
|
||||||
|
{s.last_message || `会话 #${s.id}`}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 flex-wrap text-xs text-neutral-400">
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<UserOutlined className="text-[11px]" />
|
||||||
|
客服: {resolveAgentName(s)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<ClockCircleOutlined className="text-[11px]" />
|
||||||
|
{formatRange(s.created_at, s.ended_at)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<FieldTimeOutlined className="text-[11px]" />
|
||||||
|
{formatDuration(s.created_at, s.ended_at, s.status)}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 whitespace-nowrap">
|
||||||
|
<MessageOutlined className="text-[11px]" />
|
||||||
|
{displayMsgCount}条消息
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto">
|
||||||
|
<Stars score={s.satisfaction_score} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{events.length > 0 && (
|
{total > pageSize && (
|
||||||
<div className="bg-white rounded-xl border border-neutral-200 p-5">
|
<div className="flex justify-center mt-4 pb-2">
|
||||||
<div className="text-sm font-medium text-neutral-700 mb-3">操作记录</div>
|
<Pagination
|
||||||
<div className="space-y-2">
|
current={page}
|
||||||
{events.slice().reverse().map(ev => (
|
total={total}
|
||||||
<div key={ev.id} className="flex gap-3 text-xs text-neutral-500 border-b border-neutral-50 pb-2">
|
pageSize={pageSize}
|
||||||
<span className="shrink-0 text-neutral-400 w-36">
|
showSizeChanger
|
||||||
{new Date(ev.created_at).toLocaleString('zh-CN')}
|
pageSizeOptions={[10, 20, 50]}
|
||||||
</span>
|
onChange={(p, ps) => {
|
||||||
<Tag className="m-0 text-xs">{ev.action}</Tag>
|
setPage(p)
|
||||||
<span className="flex-1">{ev.detail}</span>
|
if (ps !== pageSize) {
|
||||||
</div>
|
setPageSize(ps)
|
||||||
))}
|
setPage(1)
|
||||||
</div>
|
}
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="h-full flex flex-col items-center justify-center text-neutral-400 gap-2">
|
|
||||||
<UserOutlined className="text-2xl" />
|
{/* 右侧详情 — 与列表同顶 */}
|
||||||
<div className="text-sm">选择一个对话查看详情</div>
|
<aside className="w-[min(600px,42vw)] min-w-[360px] shrink-0 flex flex-col overflow-hidden bg-white">
|
||||||
</div>
|
{selected ? (
|
||||||
)}
|
<>
|
||||||
|
{/* 标题 + meta 合成顶区,一条底边与列表内容区视觉一致 */}
|
||||||
|
<div className="shrink-0 border-b border-neutral-200 bg-white">
|
||||||
|
<div className="flex items-center justify-between px-4 h-12">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="font-medium text-neutral-900 truncate">会话详情</span>
|
||||||
|
<span className="text-sm text-neutral-400 truncate">- {customerName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{canArchive && selected.status === 'ended' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-7 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
|
||||||
|
onClick={() => handleArchiveOne(selected.id)}
|
||||||
|
>
|
||||||
|
归档
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
|
||||||
|
onClick={() => setSelectedId(null)}
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-neutral-400 whitespace-nowrap">渠道</span>
|
||||||
|
{(() => {
|
||||||
|
const ch = channelLabel(selected)
|
||||||
|
return <TagChip label={ch.label} bg={ch.bg} color={ch.color} />
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-xs text-neutral-400 whitespace-nowrap">客服</span>
|
||||||
|
<span className="text-xs text-neutral-700 truncate">{agentName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-neutral-400 whitespace-nowrap">开始</span>
|
||||||
|
<span className="text-xs text-neutral-700">{formatDateTime(selected.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-neutral-400 whitespace-nowrap">时长</span>
|
||||||
|
<span className="text-xs text-neutral-700">
|
||||||
|
{formatDuration(selected.created_at, selected.ended_at, selected.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息流 */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4 bg-neutral-50">
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex justify-center py-16"><Spin /></div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<span className="rounded-full px-3 py-1 text-xs bg-neutral-200 text-neutral-500">
|
||||||
|
会话开始 · {formatDateTime(selected.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<Empty className="py-8" description="暂无消息" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map(msg => {
|
||||||
|
const isAgent = msg.sender_type === 'agent'
|
||||||
|
const initial = isAgent
|
||||||
|
? (agentName || '客').slice(0, 1)
|
||||||
|
: (customerName || '访').slice(0, 1)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex items-end gap-2 max-w-[85%] ${isAgent ? 'ml-auto flex-row-reverse' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-semibold shrink-0 ${
|
||||||
|
isAgent ? 'bg-[#2563eb] text-white' : 'bg-[#dbeafe] text-[#2563eb]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`rounded-lg px-3 py-2 ${
|
||||||
|
isAgent
|
||||||
|
? 'bg-[#eff6ff] border border-[#dbeafe]'
|
||||||
|
: 'bg-white border border-neutral-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.type === 'image' ? (
|
||||||
|
<ChatImage src={msg.content} alt="图片" className="max-w-48 max-h-48" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-800 m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-neutral-400 mt-0.5 block">{formatTime(msg.sent_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{timelineEvents.filter(ev => ev.action !== 'end' && ev.action !== 'archive').length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1.5">
|
||||||
|
{timelineEvents
|
||||||
|
.filter(ev => ev.action !== 'end' && ev.action !== 'archive')
|
||||||
|
.map(ev => (
|
||||||
|
<div key={ev.id} className="flex justify-center">
|
||||||
|
<span className="rounded-full px-3 py-1 text-[11px] bg-neutral-100 text-neutral-500 max-w-full truncate">
|
||||||
|
{ev.label}
|
||||||
|
{ev.detail ? ` · ${ev.detail}` : ''}
|
||||||
|
{' · '}
|
||||||
|
{formatDateTime(ev.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(selected.status === 'ended' || selected.status === 'archived') && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<span className="rounded-full px-3 py-1 text-xs bg-neutral-200 text-neutral-500">
|
||||||
|
会话{selected.status === 'archived' ? '归档' : '结束'}
|
||||||
|
{selected.ended_at ? ` · ${formatDateTime(selected.ended_at)}` : ''}
|
||||||
|
{` · 共${msgCount}条消息`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(selected.satisfaction_score != null && selected.satisfaction_score > 0) && (
|
||||||
|
<div className="flex justify-center mt-1">
|
||||||
|
<div className="flex flex-col items-center gap-1 rounded-lg px-4 py-2 bg-white border border-neutral-200">
|
||||||
|
<span className="text-xs text-neutral-400">客户评价</span>
|
||||||
|
<Stars score={selected.satisfaction_score} size={14} />
|
||||||
|
{selected.satisfaction_text && (
|
||||||
|
<span className="text-xs text-neutral-500 text-center max-w-[240px]">
|
||||||
|
{selected.satisfaction_text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-neutral-400 gap-2 bg-neutral-50">
|
||||||
|
<MessageOutlined className="text-2xl" />
|
||||||
|
<div className="text-sm">选择一个对话查看详情</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+112
-107
@@ -451,111 +451,113 @@ const Dashboard = () => {
|
|||||||
onChange={event => { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }}
|
onChange={event => { handleImage(event.target.files?.[0]); event.currentTarget.value = '' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 会话列表面板 320px */}
|
{/* 会话列表面板 320px — 顶栏固定 56px,与中/右对齐 */}
|
||||||
<section className="w-[320px] shrink-0 flex flex-col h-full border-r border-neutral-200 bg-white">
|
<section className="w-[320px] shrink-0 flex flex-col h-full border-r border-neutral-200 bg-white">
|
||||||
<div className="shrink-0 px-3 py-3 border-b border-neutral-200">
|
<div
|
||||||
<div className="flex items-center gap-2 mb-2">
|
className="shrink-0 px-3 flex items-center gap-2 border-b border-neutral-200"
|
||||||
<div className="flex items-center flex-1 min-w-0 rounded-lg px-2.5 h-[34px] bg-neutral-100 border border-neutral-200">
|
style={{ height: 'var(--header-height)' }}
|
||||||
<SearchOutlined className="text-neutral-400 text-xs mr-1.5 shrink-0" />
|
>
|
||||||
<input
|
<div className="flex items-center flex-1 min-w-0 rounded-lg px-2.5 h-8 bg-neutral-100 border border-neutral-200">
|
||||||
className="bg-transparent border-none outline-none flex-1 min-w-0 text-sm text-neutral-900 placeholder:text-neutral-400"
|
<SearchOutlined className="text-neutral-400 text-xs mr-1.5 shrink-0" />
|
||||||
placeholder="搜索访客或会话"
|
<input
|
||||||
value={search}
|
className="bg-transparent border-none outline-none flex-1 min-w-0 text-sm text-neutral-900 placeholder:text-neutral-400"
|
||||||
onChange={e => setSearch(e.target.value)}
|
placeholder="搜索访客或会话"
|
||||||
/>
|
value={search}
|
||||||
</div>
|
onChange={e => setSearch(e.target.value)}
|
||||||
<Popover
|
/>
|
||||||
open={filterOpen}
|
</div>
|
||||||
onOpenChange={setFilterOpen}
|
<span
|
||||||
trigger="click"
|
className="inline-flex items-center h-8 px-2 rounded-md text-xs font-medium bg-[#dbeafe] text-[#2563eb] whitespace-nowrap shrink-0"
|
||||||
placement="bottomRight"
|
title={`当前会话 ${filteredSessions.length} 个`}
|
||||||
content={(
|
>
|
||||||
<div className="w-52 space-y-3">
|
{filteredSessions.length}
|
||||||
<div>
|
</span>
|
||||||
<div className="text-xs text-neutral-400 mb-1.5">会话状态</div>
|
<Popover
|
||||||
<div className="flex flex-wrap gap-1.5">
|
open={filterOpen}
|
||||||
{([
|
onOpenChange={setFilterOpen}
|
||||||
{ key: 'all', label: '全部' },
|
trigger="click"
|
||||||
{ key: 'waiting', label: '等待中' },
|
placement="bottomRight"
|
||||||
{ key: 'active', label: '进行中' },
|
content={(
|
||||||
] as const).map(item => (
|
<div className="w-52 space-y-3">
|
||||||
<button
|
<div>
|
||||||
key={item.key}
|
<div className="text-xs text-neutral-400 mb-1.5">会话状态</div>
|
||||||
type="button"
|
<div className="flex flex-wrap gap-1.5">
|
||||||
onClick={() => setStatusFilter(item.key)}
|
{([
|
||||||
className={`px-2 py-0.5 rounded-md text-xs border ${
|
{ key: 'all', label: '全部' },
|
||||||
statusFilter === item.key
|
{ key: 'waiting', label: '等待中' },
|
||||||
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
{ key: 'active', label: '进行中' },
|
||||||
: 'bg-white border-neutral-200 text-neutral-600'
|
] as const).map(item => (
|
||||||
}`}
|
<button
|
||||||
>
|
key={item.key}
|
||||||
{item.label}
|
type="button"
|
||||||
</button>
|
onClick={() => setStatusFilter(item.key)}
|
||||||
))}
|
className={`px-2 py-0.5 rounded-md text-xs border ${
|
||||||
</div>
|
statusFilter === item.key
|
||||||
|
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||||
|
: 'bg-white border-neutral-200 text-neutral-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div className="text-xs text-neutral-400 mb-1.5">优先级</div>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{([
|
|
||||||
{ key: 'all', label: '全部' },
|
|
||||||
{ key: 'urgent', label: '仅紧急' },
|
|
||||||
] as const).map(item => (
|
|
||||||
<button
|
|
||||||
key={item.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPriorityFilter(item.key)}
|
|
||||||
className={`px-2 py-0.5 rounded-md text-xs border ${
|
|
||||||
priorityFilter === item.key
|
|
||||||
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
|
||||||
: 'bg-white border-neutral-200 text-neutral-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{filterActive && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs text-neutral-500 hover:text-neutral-700"
|
|
||||||
onClick={() => { setStatusFilter('all'); setPriorityFilter('all') }}
|
|
||||||
>
|
|
||||||
清除筛选
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div>
|
||||||
|
<div className="text-xs text-neutral-400 mb-1.5">优先级</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{([
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'urgent', label: '仅紧急' },
|
||||||
|
] as const).map(item => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriorityFilter(item.key)}
|
||||||
|
className={`px-2 py-0.5 rounded-md text-xs border ${
|
||||||
|
priorityFilter === item.key
|
||||||
|
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||||
|
: 'bg-white border-neutral-200 text-neutral-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filterActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-700"
|
||||||
|
onClick={() => { setStatusFilter('all'); setPriorityFilter('all') }}
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-8 h-8 rounded-lg border flex items-center justify-center shrink-0 relative ${
|
||||||
|
filterActive
|
||||||
|
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||||
|
: 'bg-neutral-100 border-neutral-200 text-neutral-500'
|
||||||
|
}`}
|
||||||
|
title="筛选"
|
||||||
>
|
>
|
||||||
<button
|
<FilterOutlined className="text-xs" />
|
||||||
type="button"
|
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#2563eb]" />}
|
||||||
className={`w-[34px] h-[34px] rounded-lg border flex items-center justify-center shrink-0 relative ${
|
</button>
|
||||||
filterActive
|
</Popover>
|
||||||
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
<a
|
||||||
: 'bg-neutral-100 border-neutral-200 text-neutral-500'
|
href="/widget/preview"
|
||||||
}`}
|
target="_blank"
|
||||||
title="筛选"
|
rel="noreferrer"
|
||||||
>
|
className="w-8 h-8 rounded-lg border border-neutral-200 bg-neutral-100 text-[#2563eb] hover:bg-[#eff6ff] flex items-center justify-center shrink-0"
|
||||||
<FilterOutlined className="text-xs" />
|
title="预览访客窗口"
|
||||||
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#2563eb]" />}
|
>
|
||||||
</button>
|
<ExportOutlined className="text-xs" />
|
||||||
</Popover>
|
</a>
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-[#dbeafe] text-[#2563eb]">
|
|
||||||
当前会话 {filteredSessions.length} 个
|
|
||||||
</span>
|
|
||||||
<a
|
|
||||||
href="/widget/preview"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs text-[#2563eb] hover:bg-[#eff6ff]"
|
|
||||||
>
|
|
||||||
<ExportOutlined className="text-[10px]" />
|
|
||||||
预览访客窗口
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto no-scrollbar">
|
<div className="flex-1 overflow-y-auto no-scrollbar">
|
||||||
@@ -631,10 +633,13 @@ const Dashboard = () => {
|
|||||||
<section className="flex-1 flex flex-col min-w-0 h-full bg-neutral-50">
|
<section className="flex-1 flex flex-col min-w-0 h-full bg-neutral-50">
|
||||||
{selected && selectedCustomer ? (
|
{selected && selectedCustomer ? (
|
||||||
<>
|
<>
|
||||||
<div className="shrink-0 flex items-center justify-between px-5 py-2.5 bg-white border-b border-neutral-200 min-h-14">
|
<div
|
||||||
|
className="shrink-0 flex items-center justify-between px-5 bg-white border-b border-neutral-200"
|
||||||
|
style={{ height: 'var(--header-height)' }}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div
|
<div
|
||||||
className="w-[38px] h-[38px] rounded-full flex items-center justify-center shrink-0 text-base font-semibold"
|
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
|
||||||
style={{
|
style={{
|
||||||
background: listStatusMeta(selected).avatarBg,
|
background: listStatusMeta(selected).avatarBg,
|
||||||
color: listStatusMeta(selected).avatarColor,
|
color: listStatusMeta(selected).avatarColor,
|
||||||
@@ -642,9 +647,9 @@ const Dashboard = () => {
|
|||||||
>
|
>
|
||||||
{selectedCustomer.name.slice(0, 1)}
|
{selectedCustomer.name.slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 leading-tight">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate font-semibold text-base text-neutral-900">{selectedCustomer.name}</span>
|
<span className="truncate font-semibold text-[15px] text-neutral-900">{selectedCustomer.name}</span>
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-[#ecfeff] text-[#0891b2]">
|
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-[#ecfeff] text-[#0891b2]">
|
||||||
{channelLabel(selectedCustomer.source)}
|
{channelLabel(selectedCustomer.source)}
|
||||||
</span>
|
</span>
|
||||||
@@ -653,8 +658,8 @@ const Dashboard = () => {
|
|||||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 与设计稿一致:姓名下方展示 IP | 地区 */}
|
{/* IP | 地区 — 压缩行高,保证三栏顶栏等高 */}
|
||||||
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-neutral-400 min-w-0">
|
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-neutral-400 min-w-0">
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
IP: {selected.visitor_ip || '—'}
|
IP: {selected.visitor_ip || '—'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+28
-2
@@ -8,10 +8,17 @@ export interface Session {
|
|||||||
status: string; priority: string; unread_count: number; satisfaction_score: number | null
|
status: string; priority: string; unread_count: number; satisfaction_score: number | null
|
||||||
last_message?: string; last_message_at?: string | null
|
last_message?: string; last_message_at?: string | null
|
||||||
satisfaction_text?: string
|
satisfaction_text?: string
|
||||||
|
end_reason?: string
|
||||||
visitor_ip?: string
|
visitor_ip?: string
|
||||||
visitor_region?: string
|
visitor_region?: string
|
||||||
user_agent?: string
|
user_agent?: string
|
||||||
created_at: string; ended_at: string | null
|
created_at: string; ended_at: string | null
|
||||||
|
/** 列表接口补全字段 */
|
||||||
|
message_count?: number
|
||||||
|
customer_name?: string
|
||||||
|
agent_name?: string
|
||||||
|
channel_name?: string
|
||||||
|
channel_type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
@@ -88,10 +95,25 @@ export interface StatisticsKpis {
|
|||||||
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
|
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
|
||||||
|
|
||||||
// Sessions
|
// Sessions
|
||||||
export const getSessions = (params?: { status?: string; priority?: string; page?: number; pageSize?: number }) => {
|
export const getSessions = (params?: {
|
||||||
|
status?: string
|
||||||
|
priority?: string
|
||||||
|
agent_id?: number | string
|
||||||
|
channel_id?: number | string
|
||||||
|
search?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}) => {
|
||||||
const search = new URLSearchParams()
|
const search = new URLSearchParams()
|
||||||
if (params?.status) search.set('status', params.status)
|
if (params?.status) search.set('status', params.status)
|
||||||
if (params?.priority) search.set('priority', params.priority)
|
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)
|
||||||
if (params?.page) search.set('page', String(params.page))
|
if (params?.page) search.set('page', String(params.page))
|
||||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||||
return getList<Session>(`/sessions?${search}`)
|
return getList<Session>(`/sessions?${search}`)
|
||||||
@@ -101,11 +123,15 @@ export const assignSession = (id: number, agentId: number) => post(`/sessions/${
|
|||||||
export const claimSession = (id: number) => post(`/sessions/${id}/assign`, {})
|
export const claimSession = (id: number) => post(`/sessions/${id}/assign`, {})
|
||||||
export const transferSession = (id: number, agentId: number) => post(`/sessions/${id}/transfer`, { agent_id: agentId })
|
export const transferSession = (id: number, agentId: number) => post(`/sessions/${id}/transfer`, { agent_id: agentId })
|
||||||
export const endSession = (id: number, reason: string) => post(`/sessions/${id}/end?reason=${reason}`, {})
|
export const endSession = (id: number, reason: string) => post(`/sessions/${id}/end?reason=${reason}`, {})
|
||||||
|
export const archiveSession = (id: number) => post<{ message: string; session: Session }>(`/sessions/${id}/archive`, {})
|
||||||
|
export const batchArchiveSessions = (ids: number[]) => post<{ message: string; count: number }>('/sessions/batch-archive', { ids })
|
||||||
export const updateSessionPriority = (id: number, priority: 'normal' | 'urgent') => put(`/sessions/${id}/priority?priority=${priority}`, {})
|
export const updateSessionPriority = (id: number, priority: 'normal' | 'urgent') => put(`/sessions/${id}/priority?priority=${priority}`, {})
|
||||||
export const markSessionRead = (id: number) => post(`/sessions/${id}/read`, {})
|
export const markSessionRead = (id: number) => post(`/sessions/${id}/read`, {})
|
||||||
export const addSessionNote = (id: number, content: string) => post<SessionEvent>(`/sessions/${id}/notes`, { content })
|
export const addSessionNote = (id: number, content: string) => post<SessionEvent>(`/sessions/${id}/notes`, { content })
|
||||||
export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post<Message>(`/sessions/${id}/messages`, { content, type })
|
export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post<Message>(`/sessions/${id}/messages`, { content, type })
|
||||||
export const getAvailableAgents = () => get<AvailableAgent[]>('/agents/available')
|
/** onlineOnly 默认 true;传 false 返回全部坐席(对话记录筛选) */
|
||||||
|
export const getAvailableAgents = (onlineOnly = true) =>
|
||||||
|
get<AvailableAgent[]>(`/agents/available${onlineOnly ? '' : '?all=1'}`)
|
||||||
|
|
||||||
export interface UploadImageResult {
|
export interface UploadImageResult {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
Reference in New Issue
Block a user