实现对话记录页并对齐工作台三栏布局
完善会话列表筛选与摘要字段、归档接口;对话记录按效果图重构;工作台顶栏等高对齐;默认管理员账号改为 kefu_admin / kefu_admin123。
This commit is contained in:
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user