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

完善会话列表筛选与摘要字段、归档接口;对话记录按效果图重构;工作台顶栏等高对齐;默认管理员账号改为 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
+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}>
+644 -182
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 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
type="button"
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-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 (
<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' : ''}`}
onClick={() => setSelectedId(s.id)}
>
<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>
)}
<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-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>
)}
{/* 列表 | 详情 — 顶边同一基线 */}
<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>
<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">
{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 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={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
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 }}
>
{name.slice(0, 1)}
</div>
<MoodBadge score={s.satisfaction_score} />
</div>
</div>
{message.type === 'image' ? (
<ChatImage src={message.content} alt="图片" className="max-w-56 max-h-56" />
) : (
<div className="whitespace-pre-wrap break-words">{message.content}</div>
)}
<div className={`text-xs mt-1 ${isAgent ? 'text-white/60' : 'text-neutral-400'}`}>
{new Date(message.sent_at).toLocaleString('zh-CN')}
<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>
</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')}
</span>
<Tag className="m-0 text-xs">{ev.action}</Tag>
<span className="flex-1">{ev.detail}</span>
</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 className="h-full flex flex-col items-center justify-center text-neutral-400 gap-2">
<UserOutlined className="text-2xl" />
<div className="text-sm"></div>
</div>
)}
</div>
{/* 右侧详情 — 与列表同顶 */}
<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-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>
)
+112 -107
View File
@@ -451,111 +451,113 @@ 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">
<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"
placeholder="搜索访客或会话"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<Popover
open={filterOpen}
onOpenChange={setFilterOpen}
trigger="click"
placement="bottomRight"
content={(
<div className="w-52 space-y-3">
<div>
<div className="text-xs text-neutral-400 mb-1.5"></div>
<div className="flex flex-wrap gap-1.5">
{([
{ key: 'all', label: '全部' },
{ key: 'waiting', label: '等待中' },
{ key: 'active', label: '进行中' },
] as const).map(item => (
<button
key={item.key}
type="button"
onClick={() => setStatusFilter(item.key)}
className={`px-2 py-0.5 rounded-md text-xs border ${
statusFilter === item.key
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
: 'bg-white border-neutral-200 text-neutral-600'
}`}
>
{item.label}
</button>
))}
</div>
<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"
placeholder="搜索访客或会话"
value={search}
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}
trigger="click"
placement="bottomRight"
content={(
<div className="w-52 space-y-3">
<div>
<div className="text-xs text-neutral-400 mb-1.5"></div>
<div className="flex flex-wrap gap-1.5">
{([
{ key: 'all', label: '全部' },
{ key: 'waiting', label: '等待中' },
{ key: 'active', label: '进行中' },
] as const).map(item => (
<button
key={item.key}
type="button"
onClick={() => setStatusFilter(item.key)}
className={`px-2 py-0.5 rounded-md text-xs border ${
statusFilter === item.key
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
: 'bg-white border-neutral-200 text-neutral-600'
}`}
>
{item.label}
</button>
))}
</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 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
type="button"
className={`w-[34px] h-[34px] 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="筛选"
>
<FilterOutlined className="text-xs" />
{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]"
>
<ExportOutlined className="text-[10px]" />
访
</a>
</div>
<FilterOutlined className="text-xs" />
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#2563eb]" />}
</button>
</Popover>
<a
href="/widget/preview"
target="_blank"
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"
title="预览访客窗口"
>
<ExportOutlined className="text-xs" />
</a>
</div>
<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">
{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>