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

完善会话列表筛选与摘要字段、归档接口;对话记录按效果图重构;工作台顶栏等高对齐;默认管理员账号改为 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() {
hash, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
// 默认后台管理员:kefu_admin / kefu_admin123(其它种子账号同密码)
hash, _ := bcrypt.GenerateFromPassword([]byte("kefu_admin123"), bcrypt.DefaultCost)
pwd := string(hash)
// Plans
@@ -48,7 +49,7 @@ func seed() {
// Tenant 1 users
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{
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: "知识库批量导入功能已上线", Status: "published"},
})
log.Println("默认租户管理员: kefu_admin / kefu_admin123")
log.Println("其它种子账号密码均为: kefu_admin123")
}
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
if password == "" {
password = "password123"
password = "kefu_admin123"
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
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("/:id", session.Get)
sessions.POST("", session.Create)
sessions.POST("/batch-archive", session.BatchArchive)
sessions.POST("/:id/assign", session.Assign)
sessions.POST("/:id/transfer", session.Transfer)
sessions.POST("/:id/end", session.End)
sessions.POST("/:id/archive", session.Archive)
sessions.PUT("/:id/priority", session.UpdatePriority)
sessions.POST("/:id/messages", session.SendMessage)
sessions.POST("/:id/read", session.MarkRead)
+195 -10
View File
@@ -27,6 +27,11 @@ type SessionListItem struct {
UnreadCount int `json:"unread_count"`
LastMessage string `json:"last_message"`
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 {
@@ -169,33 +174,125 @@ func (h *SessionHandler) List(c *gin.Context) {
page, pageSize := middleware.GetPageParams(c)
status := c.Query("status")
priority := c.Query("priority")
agentID := c.Query("agent_id")
channelID := c.Query("channel_id")
search := strings.TrimSpace(c.Query("search"))
from := c.Query("from")
to := c.Query("to")
var sessions []model.Session
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" {
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 != "" {
query = query.Where("status = ?", status)
query = query.Where("sessions.status = ?", status)
}
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)
if err := query.Order("created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil {
query.Count(&total)
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": "查询会话失败"})
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))
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) {
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
if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil {
if lastMsg.Type == "image" {
@@ -216,6 +313,22 @@ func (h *SessionHandler) List(c *gin.Context) {
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) {
id := c.Param("id")
@@ -299,10 +412,14 @@ func (h *SessionHandler) ListAvailableAgents(c *gin.Context) {
Nickname string `json:"nickname"`
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
if err := model.DB.Where("tenant_id = ? AND role = ? AND status = ?", middleware.GetTenantID(c), "agent", "online").
Order("nickname asc").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"})
if err := q.Order("nickname asc").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
return
}
items := make([]agentItem, 0, len(users))
@@ -523,3 +640,71 @@ func (h *SessionHandler) UpdatePriority(c *gin.Context) {
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>
<Form form={form} layout="vertical" onFinish={onFinish} autoComplete="off">
<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 name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="password123" size="large" />
<Input.Password placeholder="kefu_admin123" size="large" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
+628 -166
View File
@@ -1,48 +1,185 @@
import { useState, useEffect, useMemo } from 'react'
import { Input, Select, Tag, Empty, Spin } from 'antd'
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
import { useState, useEffect, useMemo, useCallback, type MouseEvent } from 'react'
import { Empty, Spin, Select, DatePicker, message, Pagination, Checkbox } from 'antd'
import {
getCustomers, getSession, getSessions,
type Customer, type Message, type Session, type SessionEvent,
SearchOutlined, DownloadOutlined, InboxOutlined, CloseOutlined,
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'
import { ChatImage } from '@/components/common/ImagePreview'
import { useAuth } from '@/stores/auth'
const statusColors: Record<string, string> = {
active: 'blue', waiting: 'orange', ended: 'green', archived: 'default',
const { RangePicker } = DatePicker
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 { user } = useAuth()
const canArchive = user?.role === 'admin' || user?.role === 'supervisor'
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 [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>()
const [priorityFilter, setPriorityFilter] = useState<string>()
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
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 [messages, setMessages] = useState<Message[]>([])
const [events, setEvents] = useState<SessionEvent[]>([])
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [checkedIds, setCheckedIds] = useState<number[]>([])
const [archiving, setArchiving] = useState(false)
useEffect(() => {
loadSessions()
}, [statusFilter, priorityFilter])
const loadSessions = async () => {
const loadSessions = useCallback(async () => {
setLoading(true)
try {
const [sessionRes, customerRes] = await Promise.all([
getSessions({ status: statusFilter, priority: priorityFilter, page: 1, pageSize: 100 }),
getCustomers({ page: 1, pageSize: 200 }),
])
const list = Array.isArray(sessionRes.list) ? sessionRes.list : []
const res = await getSessions({
status: statusFilter,
agent_id: agentFilter,
channel_id: channelFilter,
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)
const map = Object.fromEntries((customerRes.list || []).map(c => [c.id, c]))
setCustomers(map)
setTotal(res.total || 0)
setCheckedIds([])
if (list.length > 0) {
const still = selectedId && list.some(s => s.id === selectedId)
if (!still) setSelectedId(list[0].id)
@@ -51,10 +188,25 @@ const ChatHistory = () => {
}
} catch {
setSessions([])
setTotal(0)
} finally {
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(() => {
if (!selectedId) {
@@ -65,191 +217,501 @@ const ChatHistory = () => {
}
setDetailLoading(true)
getSession(selectedId).then(res => {
setMessages(res.data.messages || [])
const msgs = res.data.messages || []
setMessages(msgs)
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(() => {
setMessages([])
setEvents([])
}).finally(() => setDetailLoading(false))
}, [selectedId])
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase()
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) || null
const customerName = selected?.customer_name || (selected ? `客户${selected.customer_id}` : '')
const selected = selectedSession || sessions.find(s => s.id === selectedId)
const customer = selected ? customers[selected.customer_id] : null
const agentNameById = useMemo(() => {
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 (
<div className="h-full flex">
<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">
<Input
prefix={<SearchOutlined />}
placeholder="搜索客户/会话/消息"
value={search}
onChange={e => setSearch(e.target.value)}
allowClear
size="small"
/>
<div className="flex gap-2">
<Select
placeholder="状态"
value={statusFilter}
onChange={setStatusFilter}
allowClear
size="small"
className="flex-1"
options={Object.entries(statusLabels).map(([k, v]) => ({ value: k, label: v }))}
/>
<Select
placeholder="优先级"
value={priorityFilter}
onChange={setPriorityFilter}
allowClear
size="small"
className="flex-1"
options={Object.entries(priorityLabels).map(([k, v]) => ({ value: k, label: v }))}
/>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex justify-center py-10"><Spin /></div>
) : filtered.map(s => {
const name = customers[s.customer_id]?.name || `客户${s.customer_id}`
return (
<div className="h-full flex flex-col min-h-0 bg-neutral-50 overflow-hidden">
{/* 顶栏 — 全宽 */}
<header className="shrink-0 h-14 px-6 flex items-center justify-between bg-white border-b border-neutral-200">
<h1 className="text-lg font-semibold text-neutral-900 m-0"></h1>
<div className="flex items-center gap-2">
<button
key={s.id}
type="button"
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' : ''}`}
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"
onClick={() => message.info('导出功能后续版本提供')}
>
<DownloadOutlined />
</button>
<button
type="button"
disabled={archiving}
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"
onClick={handleBatchArchive}
>
<InboxOutlined />
</button>
</div>
</header>
{/* 筛选栏 — 全宽,左右分栏顶边对齐 */}
<div className="shrink-0 px-6 py-3 bg-white border-b border-neutral-200">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<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>
<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 className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5">
<span className="text-sm text-neutral-500 whitespace-nowrap">:</span>
<Select
allowClear
placeholder="全部"
value={channelFilter}
onChange={v => { setChannelFilter(v); setPage(1) }}
className="!w-[120px]"
size="small"
options={channels.map(ch => ({ value: ch.id, label: ch.name || ch.type }))}
/>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm text-neutral-500 whitespace-nowrap">:</span>
<Select
allowClear
placeholder="全部"
value={agentFilter}
onChange={v => { setAgentFilter(v); setPage(1) }}
className="!w-[120px]"
size="small"
options={agents.map(a => ({ value: a.id, label: a.nickname }))}
/>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm text-neutral-500 whitespace-nowrap">:</span>
<Select
allowClear
placeholder="全部"
value={statusFilter}
onChange={v => { setStatusFilter(v); setPage(1) }}
className="!w-[120px]"
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 className="flex flex-1 min-h-0 overflow-hidden">
{/* 会话列表 */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden border-r border-neutral-200">
<div className="flex-1 overflow-y-auto px-6 py-4 bg-neutral-50">
{loading ? (
<div className="flex justify-center py-20"><Spin size="large" /></div>
) : sessions.length === 0 ? (
<div className="flex items-center justify-center h-48">
<Empty description="暂无对话记录" />
</div>
) : (
<div className="flex flex-col gap-3">
{sessions.map(s => {
const name = s.customer_name || `客户${s.customer_id}`
const pal = avatarPalette(name)
const ch = channelLabel(s)
const st = statusMeta[s.status] || statusMeta.ended
const reason = s.end_reason ? endReasonMeta[s.end_reason] : null
const active = selectedId === s.id
const checked = checkedIds.includes(s.id)
const displayMsgCount = selectedId === s.id && messages.length > 0
? messages.length
: (s.message_count ?? 0)
return (
<div
key={s.id}
role="button"
tabIndex={0}
onClick={() => setSelectedId(s.id)}
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 }}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<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">
{name.slice(0, 1)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-neutral-800 truncate">{name}</div>
<div className="text-xs text-neutral-400 truncate">{s.last_message || `会话 #${s.id}`}</div>
</div>
</div>
<Tag color={statusColors[s.status]} className="text-xs m-0 shrink-0">{statusLabels[s.status] || s.status}</Tag>
</div>
<div className="flex items-center justify-between mt-1.5 pl-10">
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleString('zh-CN')}</span>
{s.priority === 'urgent' && <Tag color="red" className="text-xs m-0"></Tag>}
{s.satisfaction_score ? <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span> : null}
</div>
</button>
)
})}
{!loading && filtered.length === 0 && (
<div className="flex items-center justify-center h-40"><Empty description="暂无对话记录" /></div>
)}
<MoodBadge score={s.satisfaction_score} />
</div>
</div>
<div className="flex-1 bg-neutral-50 overflow-auto">
{selected ? (
<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 items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-semibold text-neutral-800 m-0 truncate">
{customer?.name || `客户${selected.customer_id}`}
</h3>
<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 className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-neutral-900 truncate">{name}</span>
<TagChip label={ch.label} bg={ch.bg} color={ch.color} />
{reason && <TagChip label={reason.label} bg={reason.bg} color={reason.color} />}
{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>
)}
{total > pageSize && (
<div className="flex justify-center mt-4 pb-2">
<Pagination
current={page}
total={total}
pageSize={pageSize}
showSizeChanger
pageSizeOptions={[10, 20, 50]}
onChange={(p, ps) => {
setPage(p)
if (ps !== pageSize) {
setPageSize(ps)
setPage(1)
}
}}
size="small"
/>
</div>
)}
</div>
</div>
<div className="bg-white rounded-xl border border-neutral-200 p-5 mb-4">
<div className="text-sm font-medium text-neutral-700 mb-4"></div>
<div className="space-y-4">
{/* 右侧详情 — 与列表同顶 */}
<aside className="w-[min(600px,42vw)] min-w-[360px] shrink-0 flex flex-col overflow-hidden bg-white">
{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-10"><Spin /></div>
) : messages.length === 0 ? (
<Empty description="暂无消息记录" />
) : messages.map(message => {
const isAgent = message.sender_type === 'agent'
<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={message.id} className={`flex ${isAgent ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[70%] rounded-xl px-3.5 py-2.5 text-sm ${
isAgent ? 'bg-[#2563eb] text-white rounded-tr-sm' : 'bg-neutral-100 text-neutral-700 rounded-tl-sm'
}`}>
<div className={`text-xs mb-1 ${isAgent ? 'text-white/70' : 'text-neutral-400'}`}>
{isAgent ? '客服' : '访客'}
</div>
{message.type === 'image' ? (
<ChatImage src={message.content} alt="图片" className="max-w-56 max-h-56" />
<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" />
) : (
<div className="whitespace-pre-wrap break-words">{message.content}</div>
<p className="text-sm text-neutral-800 m-0 whitespace-pre-wrap break-words">{msg.content}</p>
)}
<div className={`text-xs mt-1 ${isAgent ? 'text-white/60' : 'text-neutral-400'}`}>
{new Date(message.sent_at).toLocaleString('zh-CN')}
<span className="text-xs text-neutral-400 mt-0.5 block">{formatTime(msg.sent_at)}</span>
</div>
</div>
</div>
)
})}
</div>
</div>
{events.length > 0 && (
<div className="bg-white rounded-xl border border-neutral-200 p-5">
<div className="text-sm font-medium text-neutral-700 mb-3"></div>
<div className="space-y-2">
{events.slice().reverse().map(ev => (
<div key={ev.id} className="flex gap-3 text-xs text-neutral-500 border-b border-neutral-50 pb-2">
<span className="shrink-0 text-neutral-400 w-36">
{new Date(ev.created_at).toLocaleString('zh-CN')}
{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>
<Tag className="m-0 text-xs">{ev.action}</Tag>
<span className="flex-1">{ev.detail}</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">
<UserOutlined className="text-2xl" />
<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>
)
+25 -20
View File
@@ -451,11 +451,13 @@ const Dashboard = () => {
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">
<div className="shrink-0 px-3 py-3 border-b border-neutral-200">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center flex-1 min-w-0 rounded-lg px-2.5 h-[34px] bg-neutral-100 border border-neutral-200">
<div
className="shrink-0 px-3 flex items-center gap-2 border-b border-neutral-200"
style={{ height: 'var(--header-height)' }}
>
<div className="flex items-center flex-1 min-w-0 rounded-lg px-2.5 h-8 bg-neutral-100 border border-neutral-200">
<SearchOutlined className="text-neutral-400 text-xs mr-1.5 shrink-0" />
<input
className="bg-transparent border-none outline-none flex-1 min-w-0 text-sm text-neutral-900 placeholder:text-neutral-400"
@@ -464,6 +466,12 @@ const Dashboard = () => {
onChange={e => setSearch(e.target.value)}
/>
</div>
<span
className="inline-flex items-center h-8 px-2 rounded-md text-xs font-medium bg-[#dbeafe] text-[#2563eb] whitespace-nowrap shrink-0"
title={`当前会话 ${filteredSessions.length}`}
>
{filteredSessions.length}
</span>
<Popover
open={filterOpen}
onOpenChange={setFilterOpen}
@@ -530,7 +538,7 @@ const Dashboard = () => {
>
<button
type="button"
className={`w-[34px] h-[34px] rounded-lg border flex items-center justify-center shrink-0 relative ${
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'
@@ -541,22 +549,16 @@ const Dashboard = () => {
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#2563eb]" />}
</button>
</Popover>
</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]"
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"
title="预览访客窗口"
>
<ExportOutlined className="text-[10px]" />
访
<ExportOutlined className="text-xs" />
</a>
</div>
</div>
<div className="flex-1 overflow-y-auto no-scrollbar">
{filteredSessions.length === 0 ? (
@@ -631,10 +633,13 @@ const Dashboard = () => {
<section className="flex-1 flex flex-col min-w-0 h-full bg-neutral-50">
{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="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={{
background: listStatusMeta(selected).avatarBg,
color: listStatusMeta(selected).avatarColor,
@@ -642,9 +647,9 @@ const Dashboard = () => {
>
{selectedCustomer.name.slice(0, 1)}
</div>
<div className="min-w-0">
<div className="min-w-0 leading-tight">
<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]">
{channelLabel(selectedCustomer.source)}
</span>
@@ -653,8 +658,8 @@ const Dashboard = () => {
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
</span>
</div>
{/* 与设计稿一致:姓名下方展示 IP | 地区 */}
<div className="flex items-center gap-1.5 mt-0.5 text-xs text-neutral-400 min-w-0">
{/* IP | 地区 — 压缩行高,保证三栏顶栏等高 */}
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-neutral-400 min-w-0">
<span className="truncate">
IP: {selected.visitor_ip || '—'}
</span>
+28 -2
View File
@@ -8,10 +8,17 @@ export interface Session {
status: string; priority: string; unread_count: number; satisfaction_score: number | null
last_message?: string; last_message_at?: string | null
satisfaction_text?: string
end_reason?: string
visitor_ip?: string
visitor_region?: string
user_agent?: string
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 {
@@ -88,10 +95,25 @@ export interface StatisticsKpis {
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
// 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()
if (params?.status) search.set('status', params.status)
if (params?.priority) search.set('priority', params.priority)
if (params?.agent_id != null && params.agent_id !== '') search.set('agent_id', String(params.agent_id))
if (params?.channel_id != null && params.channel_id !== '') search.set('channel_id', String(params.channel_id))
if (params?.search) search.set('search', params.search)
if (params?.from) search.set('from', params.from)
if (params?.to) search.set('to', params.to)
if (params?.page) search.set('page', String(params.page))
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
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 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 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 markSessionRead = (id: number) => post(`/sessions/${id}/read`, {})
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 getAvailableAgents = () => get<AvailableAgent[]>('/agents/available')
/** onlineOnly 默认 true;传 false 返回全部坐席(对话记录筛选) */
export const getAvailableAgents = (onlineOnly = true) =>
get<AvailableAgent[]>(`/agents/available${onlineOnly ? '' : '?all=1'}`)
export interface UploadImageResult {
url: string