实现对话记录页并对齐工作台三栏布局

完善会话列表筛选与摘要字段、归档接口;对话记录按效果图重构;工作台顶栏等高对齐;默认管理员账号改为 kefu_admin / kefu_admin123。
This commit is contained in:
yml2213
2026-07-15 13:12:07 +08:00
parent 6bc9247dd6
commit 32fbfb4fe1
8 changed files with 990 additions and 306 deletions
+6 -2
View File
@@ -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 }
+1 -1
View File
@@ -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 {
+2
View File
@@ -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)
+195 -10
View File
@@ -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})
}
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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