工作台右侧栏精简客户信息并内嵌快捷回复
去掉访问来源区块以缩短信息区;保留浏览轨迹、历史会话与内部备注,下方增加团队/个人快捷回复列表。
This commit is contained in:
+130
-142
@@ -193,24 +193,6 @@ function channelLabel(source?: string) {
|
||||
return source.length > 6 ? '网页' : source
|
||||
}
|
||||
|
||||
/** 从 UA 摘要设备信息,与后端 summarizeUserAgent 逻辑对齐 */
|
||||
function summarizeDevice(ua?: string) {
|
||||
if (!ua) return ''
|
||||
let browser = '浏览器'
|
||||
let osName = '未知系统'
|
||||
if (ua.includes('Edg/')) browser = 'Edge'
|
||||
else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome'
|
||||
else if (ua.includes('Firefox/')) browser = 'Firefox'
|
||||
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari'
|
||||
else if (ua.includes('MicroMessenger')) browser = '微信'
|
||||
if (ua.includes('Windows')) osName = 'Windows'
|
||||
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) osName = 'macOS'
|
||||
else if (ua.includes('Android')) osName = 'Android'
|
||||
else if (ua.includes('iPhone') || ua.includes('iPad')) osName = 'iOS'
|
||||
else if (ua.includes('Linux')) osName = 'Linux'
|
||||
return `${browser} / ${osName}`
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
messages: Message[]
|
||||
events: SessionEvent[]
|
||||
@@ -236,7 +218,6 @@ function shortPagePath(url?: string): string {
|
||||
if (!url) return '—'
|
||||
try {
|
||||
const u = new URL(url)
|
||||
// 必须带 hash:demo-shop 等 SPA 靠 #/cart 区分页面
|
||||
const path = u.pathname + (u.search || '') + (u.hash || '')
|
||||
return path.length > 56 ? `${path.slice(0, 54)}…` : path || '/'
|
||||
} catch {
|
||||
@@ -274,6 +255,11 @@ const Dashboard = () => {
|
||||
const [quickKeyword, setQuickKeyword] = useState('')
|
||||
const [quickList, setQuickList] = useState<QuickReply[]>([])
|
||||
const [quickLoading, setQuickLoading] = useState(false)
|
||||
/** 右侧栏快捷回复:团队 / 个人 */
|
||||
const [sidebarQuickScope, setSidebarQuickScope] = useState<'team' | 'personal'>('team')
|
||||
const [sidebarQuickKeyword, setSidebarQuickKeyword] = useState('')
|
||||
const [sidebarQuickList, setSidebarQuickList] = useState<QuickReply[]>([])
|
||||
const [sidebarQuickLoading, setSidebarQuickLoading] = useState(false)
|
||||
/** 输入框快捷回复建议:slash=/ 输入码;keyword=正文关键字 */
|
||||
const [suggestOpen, setSuggestOpen] = useState(false)
|
||||
const [suggestMode, setSuggestMode] = useState<'slash' | 'keyword'>('slash')
|
||||
@@ -723,6 +709,28 @@ const Dashboard = () => {
|
||||
}).catch(() => setQuickList([])).finally(() => setQuickLoading(false))
|
||||
}, [quickOpen, quickKeyword])
|
||||
|
||||
// 右侧栏快捷回复列表(始终可用)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setSidebarQuickLoading(true)
|
||||
getQuickReplies({
|
||||
scope: sidebarQuickScope,
|
||||
// 团队仅展示已发布;个人展示自己的全部可用
|
||||
status: sidebarQuickScope === 'team' ? 'published' : undefined,
|
||||
q: sidebarQuickKeyword.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}).then(response => {
|
||||
if (cancelled) return
|
||||
setSidebarQuickList(Array.isArray(response.list) ? response.list : [])
|
||||
}).catch(() => {
|
||||
if (!cancelled) setSidebarQuickList([])
|
||||
}).finally(() => {
|
||||
if (!cancelled) setSidebarQuickLoading(false)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [sidebarQuickScope, sidebarQuickKeyword])
|
||||
|
||||
const closeSuggest = useCallback(() => {
|
||||
setSuggestOpen(false)
|
||||
setSuggestItems([])
|
||||
@@ -873,7 +881,6 @@ const Dashboard = () => {
|
||||
setCustomerHistory([])
|
||||
return
|
||||
}
|
||||
// 从已加载会话中筛出该客户的历史(含已结束)
|
||||
getSessions({ page: 1, pageSize: 50 }).then(res => {
|
||||
const list = Array.isArray(res.list) ? res.list : []
|
||||
setCustomerHistory(
|
||||
@@ -1745,20 +1752,21 @@ const Dashboard = () => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右侧客户信息 320px */}
|
||||
<aside className="w-[320px] shrink-0 flex flex-col h-full border-l border-neutral-200 bg-white">
|
||||
{/* 右侧:上客户信息 + 下快捷回复 */}
|
||||
<aside className="w-[320px] shrink-0 flex flex-col h-full border-l border-neutral-200 bg-white min-h-0">
|
||||
{selectedCustomer ? (
|
||||
<>
|
||||
{/* —— 上:客户信息(精简) —— */}
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between px-4 border-b border-neutral-200"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<span className="font-semibold text-base text-neutral-800">客户信息</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 no-scrollbar">
|
||||
<div className="flex flex-col items-center mb-5">
|
||||
<div className="shrink-0 max-h-[42%] overflow-y-auto px-4 py-3 no-scrollbar border-b border-neutral-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
className="w-14 h-14 rounded-full flex items-center justify-center mb-2 text-[22px] font-semibold"
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-[16px] font-semibold shrink-0"
|
||||
style={{
|
||||
background: selected ? listStatusMeta(selected).avatarBg : '#eff6ff',
|
||||
color: selected ? listStatusMeta(selected).avatarColor : '#2563eb',
|
||||
@@ -1766,54 +1774,53 @@ const Dashboard = () => {
|
||||
>
|
||||
{selectedCustomer.name.slice(0, 1)}
|
||||
</div>
|
||||
<div className="font-semibold text-lg text-neutral-900">{selectedCustomer.name}</div>
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-neutral-400">
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[15px] text-neutral-900 truncate">{selectedCustomer.name}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[11px] text-neutral-400">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: selectedCustomer.status === 'online' ? '#16a34a' : '#94a3b8' }}
|
||||
/>
|
||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||
{selectedCustomer.source ? (
|
||||
<span className="text-neutral-300">· {selectedCustomer.source}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="flex items-center gap-1.5 mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">
|
||||
基本信息
|
||||
<span className="font-normal normal-case tracking-normal text-neutral-400">点击可编辑</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 text-sm">
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<CustomerInfoField
|
||||
label="手机"
|
||||
value={selectedCustomer.phone || ''}
|
||||
placeholder="点击填写手机号"
|
||||
placeholder="点击填写"
|
||||
saving={savingCustomerField === 'phone'}
|
||||
onSave={v => saveCustomerField(selectedCustomer.id, 'phone', v)}
|
||||
/>
|
||||
<CustomerInfoField
|
||||
label="邮箱"
|
||||
value={selectedCustomer.email || ''}
|
||||
placeholder="点击填写邮箱"
|
||||
placeholder="点击填写"
|
||||
saving={savingCustomerField === 'email'}
|
||||
onSave={v => saveCustomerField(selectedCustomer.id, 'email', v)}
|
||||
/>
|
||||
<CustomerInfoField
|
||||
label="微信"
|
||||
value={selectedCustomer.wechat || ''}
|
||||
placeholder="点击填写微信号"
|
||||
placeholder="点击填写"
|
||||
saving={savingCustomerField === 'wechat'}
|
||||
onSave={v => saveCustomerField(selectedCustomer.id, 'wechat', v)}
|
||||
/>
|
||||
<CustomerInfoField
|
||||
label="QQ"
|
||||
value={selectedCustomer.qq || ''}
|
||||
placeholder="点击填写 QQ 号"
|
||||
placeholder="点击填写"
|
||||
saving={savingCustomerField === 'qq'}
|
||||
onSave={v => saveCustomerField(selectedCustomer.id, 'qq', v)}
|
||||
/>
|
||||
{(() => {
|
||||
const kindLabel = (k: string) =>
|
||||
k === 'phone' ? '手机' : k === 'wechat' ? '微信' : k === 'email' ? '邮箱' : k === 'qq' ? 'QQ' : k
|
||||
// 主字段已展示的联系方式不重复
|
||||
const extra = customerContacts.filter(c => {
|
||||
if (c.kind === 'phone' && selectedCustomer.phone && c.value === selectedCustomer.phone) return false
|
||||
if (c.kind === 'email' && selectedCustomer.email && c.value === selectedCustomer.email) return false
|
||||
@@ -1823,125 +1830,40 @@ const Dashboard = () => {
|
||||
})
|
||||
if (extra.length === 0) return null
|
||||
return (
|
||||
<div className="flex items-start gap-2 pt-1">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12 leading-5">更多</span>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-start gap-2 pt-0.5">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12 leading-5 text-sm">更多</span>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
{extra.map(c => (
|
||||
<span key={c.id || `${c.kind}-${c.value}`} className="text-neutral-800 text-xs break-all">
|
||||
<span className="text-neutral-400 mr-1">{kindLabel(c.kind)}</span>
|
||||
{c.value}
|
||||
{(c.source === 'draft' || c.source === 'message') && (
|
||||
<span className="ml-1 text-[10px] text-amber-600">自动识别</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
<div className="flex items-start gap-2 pt-1">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">来源</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.source || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">对话</span>
|
||||
<span className="truncate text-neutral-800">{selectedCustomer.conversation_count} 次</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">地区</span>
|
||||
<span className="truncate text-neutral-800">{selected?.visitor_region || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">客户标签</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">标签</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{parseTags(selectedCustomer.tags).length === 0 ? (
|
||||
<span className="text-xs text-neutral-400">暂无标签</span>
|
||||
<span className="text-xs text-neutral-400">暂无</span>
|
||||
) : parseTags(selectedCustomer.tags).map(tag => (
|
||||
<span key={tag} className="text-xs px-2 py-0.5 rounded-md bg-[#dbeafe] text-[#2563eb] font-medium">
|
||||
<span key={tag} className="text-[11px] px-1.5 py-0.5 rounded-md bg-[#dbeafe] text-[#2563eb] font-medium">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">访问来源</div>
|
||||
<div className="flex flex-col gap-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">渠道</span>
|
||||
<span className="text-neutral-800">{channelLabel(selectedCustomer.source)}在线客服</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">设备</span>
|
||||
<span className="text-neutral-800">{summarizeDevice(selected?.user_agent) || '—'}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">落地页</span>
|
||||
{selected?.landing_url ? (
|
||||
<a
|
||||
href={selected.landing_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[#2563eb] hover:underline break-all min-w-0"
|
||||
title={selected.landing_title || selected.landing_url}
|
||||
>
|
||||
{selected.landing_title || shortPagePath(selected.landing_url)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">当前页</span>
|
||||
{selected?.current_url ? (
|
||||
<a
|
||||
href={selected.current_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-800 hover:text-[#2563eb] hover:underline break-all min-w-0"
|
||||
title={selected.current_title || selected.current_url}
|
||||
>
|
||||
{selected.current_title || shortPagePath(selected.current_url)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</div>
|
||||
{selected?.referrer ? (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">来路</span>
|
||||
<a
|
||||
href={selected.referrer}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-500 hover:underline break-all text-xs min-w-0"
|
||||
>
|
||||
{shortPagePath(selected.referrer)}
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12">在线</span>
|
||||
<span className="text-neutral-800 tabular-nums font-medium">
|
||||
{formatOnlineDuration(
|
||||
selected?.created_at,
|
||||
selected?.status === 'ended' || selected?.status === 'archived' ? selected?.ended_at : null,
|
||||
clockTick >= 0 ? Date.now() : Date.now(),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">浏览轨迹</div>
|
||||
<div className="mt-4 mb-3">
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">浏览轨迹</div>
|
||||
{!detail?.pageViews?.length ? (
|
||||
<div className="text-xs text-neutral-400">暂无页面记录(需嵌入 widget 并打开会话)</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0 max-h-48 overflow-y-auto rounded-lg border border-neutral-100">
|
||||
<div className="flex flex-col gap-0 max-h-36 overflow-y-auto rounded-lg border border-neutral-100">
|
||||
{detail.pageViews.map((pv, idx) => (
|
||||
<div
|
||||
key={pv.id || `${pv.url}-${pv.entered_at}`}
|
||||
@@ -1972,9 +1894,9 @@ const Dashboard = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-5">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">历史会话</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="mb-3">
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">历史会话</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{customerHistory.length === 0 ? (
|
||||
<div className="text-xs text-neutral-400">暂无其他会话</div>
|
||||
) : customerHistory.map(s => (
|
||||
@@ -1982,9 +1904,9 @@ const Dashboard = () => {
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
className="rounded-lg px-3 py-2 text-left bg-neutral-50 border border-neutral-100 hover:border-blue-200"
|
||||
className="rounded-lg px-2.5 py-2 text-left bg-neutral-50 border border-neutral-100 hover:border-blue-200"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="truncate text-sm font-medium text-neutral-700">会话 #{s.id}</span>
|
||||
<span className="whitespace-nowrap text-xs text-neutral-400">
|
||||
{new Date(s.created_at).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
|
||||
@@ -1998,9 +1920,9 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="mb-2.5 text-xs font-semibold text-neutral-500 uppercase tracking-wider">内部备注</div>
|
||||
<div className="space-y-2 max-h-40 overflow-auto">
|
||||
<div className="mb-1">
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">内部备注</div>
|
||||
<div className="space-y-1.5 max-h-32 overflow-auto">
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-xs text-neutral-400">暂无内部备注</div>
|
||||
) : notes.map(note => {
|
||||
@@ -2062,6 +1984,72 @@ const Dashboard = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* —— 下:快捷回复(团队 / 个人) —— */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="shrink-0 px-3 pt-2.5 pb-2 border-b border-neutral-100">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-sm font-semibold text-neutral-800">快捷回复</span>
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md bg-neutral-100">
|
||||
{([
|
||||
{ key: 'team' as const, label: '团队' },
|
||||
{ key: 'personal' as const, label: '个人' },
|
||||
]).map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setSidebarQuickScope(tab.key)}
|
||||
className={`h-6 px-2 rounded text-xs border-0 cursor-pointer transition-colors ${
|
||||
sidebarQuickScope === tab.key
|
||||
? 'bg-white text-[#2563eb] font-medium shadow-sm'
|
||||
: 'bg-transparent text-neutral-500 hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
size="small"
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-neutral-400" />}
|
||||
placeholder="搜索标题、内容或输入码"
|
||||
value={sidebarQuickKeyword}
|
||||
onChange={e => setSidebarQuickKeyword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2 no-scrollbar">
|
||||
{sidebarQuickLoading ? (
|
||||
<div className="py-8 text-center"><Spin size="small" /></div>
|
||||
) : sidebarQuickList.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-neutral-400">
|
||||
{sidebarQuickScope === 'team' ? '暂无已发布的团队话术' : '暂无个人快捷回复'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{sidebarQuickList.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
disabled={!canOperate}
|
||||
title={canOperate ? '点击填入输入框' : '领取会话后可用'}
|
||||
onClick={() => void applyQuickReply(item, 'panel')}
|
||||
className="w-full text-left rounded-lg px-2.5 py-2 border-0 bg-neutral-50 hover:bg-blue-50 hover:ring-1 hover:ring-blue-200 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[13px] font-medium text-neutral-800 truncate">{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<code className="text-[10px] text-blue-600 bg-blue-50 px-1 rounded shrink-0">/{item.shortcut}</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-neutral-500 line-clamp-2 mt-0.5 leading-snug">{item.content}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-sm text-neutral-400">选择会话查看客户信息</div>
|
||||
|
||||
Reference in New Issue
Block a user