工作台右侧栏精简客户信息并内嵌快捷回复

去掉访问来源区块以缩短信息区;保留浏览轨迹、历史会话与内部备注,下方增加团队/个人快捷回复列表。
This commit is contained in:
yml2213
2026-07-19 23:52:53 +08:00
parent ff3a058f29
commit b483cbc26d
+180 -192
View File
@@ -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)
// 必须带 hashdemo-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,182 +1774,96 @@ 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">
<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' ? '忙碌' : '离线'}
<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">
<CustomerInfoField
label="手机"
value={selectedCustomer.phone || ''}
placeholder="点击填写手机号"
saving={savingCustomerField === 'phone'}
onSave={v => saveCustomerField(selectedCustomer.id, 'phone', v)}
/>
<CustomerInfoField
label="邮箱"
value={selectedCustomer.email || ''}
placeholder="点击填写邮箱"
saving={savingCustomerField === 'email'}
onSave={v => saveCustomerField(selectedCustomer.id, 'email', v)}
/>
<CustomerInfoField
label="微信"
value={selectedCustomer.wechat || ''}
placeholder="点击填写微信号"
saving={savingCustomerField === 'wechat'}
onSave={v => saveCustomerField(selectedCustomer.id, 'wechat', v)}
/>
<CustomerInfoField
label="QQ"
value={selectedCustomer.qq || ''}
placeholder="点击填写 QQ 号"
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
if (c.kind === 'wechat' && selectedCustomer.wechat && c.value === selectedCustomer.wechat) return false
if (c.kind === 'qq' && selectedCustomer.qq && c.value === selectedCustomer.qq) return false
return true
})
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">
{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 className="flex flex-col gap-1 text-sm">
<CustomerInfoField
label="手机"
value={selectedCustomer.phone || ''}
placeholder="点击填写"
saving={savingCustomerField === 'phone'}
onSave={v => saveCustomerField(selectedCustomer.id, 'phone', v)}
/>
<CustomerInfoField
label="邮箱"
value={selectedCustomer.email || ''}
placeholder="点击填写"
saving={savingCustomerField === 'email'}
onSave={v => saveCustomerField(selectedCustomer.id, 'email', v)}
/>
<CustomerInfoField
label="微信"
value={selectedCustomer.wechat || ''}
placeholder="点击填写"
saving={savingCustomerField === 'wechat'}
onSave={v => saveCustomerField(selectedCustomer.id, 'wechat', v)}
/>
<CustomerInfoField
label="QQ"
value={selectedCustomer.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
if (c.kind === 'wechat' && selectedCustomer.wechat && c.value === selectedCustomer.wechat) return false
if (c.kind === 'qq' && selectedCustomer.qq && c.value === selectedCustomer.qq) return false
return true
})
if (extra.length === 0) return null
return (
<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}
</span>
))}
</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>
<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>