优化工作台客户标签快速选择
This commit is contained in:
@@ -4,7 +4,7 @@ import {
|
|||||||
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
||||||
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
|
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
|
||||||
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
|
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
|
||||||
StopOutlined,
|
StopOutlined, PlusOutlined, TagsOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||||
import { ChatImage } from '@/components/common/ImagePreview'
|
import { ChatImage } from '@/components/common/ImagePreview'
|
||||||
@@ -12,9 +12,9 @@ import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
|
|||||||
import { useAuth } from '@/stores/auth'
|
import { useAuth } from '@/stores/auth'
|
||||||
import {
|
import {
|
||||||
addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
||||||
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
getCustomerTags, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
||||||
suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply,
|
suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply as recordQuickReplyUsage,
|
||||||
type AvailableAgent, type BlacklistDuration, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
|
type AvailableAgent, type BlacklistDuration, type Customer, type CustomerContact, type CustomerTag, type KnowledgeEntry, type Message, type QuickReply,
|
||||||
type Session, type SessionEvent, type VisitorPageView,
|
type Session, type SessionEvent, type VisitorPageView,
|
||||||
} from '@/services/api'
|
} from '@/services/api'
|
||||||
|
|
||||||
@@ -185,6 +185,169 @@ function parseTags(tagsStr: string): string[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customerTagColorMap: Record<string, { bg: string; color: string }> = {
|
||||||
|
amber: { bg: '#fef3c7', color: '#92400e' },
|
||||||
|
green: { bg: '#f0fdf4', color: '#16a34a' },
|
||||||
|
blue: { bg: '#dbeafe', color: '#2563eb' },
|
||||||
|
cyan: { bg: '#ecfeff', color: '#0891b2' },
|
||||||
|
violet: { bg: '#f3e8ff', color: '#7c3aed' },
|
||||||
|
rose: { bg: '#fff1f2', color: '#e11d48' },
|
||||||
|
orange: { bg: '#fffbeb', color: '#d97706' },
|
||||||
|
slate: { bg: '#f1f5f9', color: '#475569' },
|
||||||
|
// 兼容历史标签名称
|
||||||
|
'VIP客户': { bg: '#fef3c7', color: '#92400e' },
|
||||||
|
'VIP': { bg: '#fef3c7', color: '#92400e' },
|
||||||
|
'新客户': { bg: '#f0fdf4', color: '#16a34a' },
|
||||||
|
'活跃': { bg: '#dbeafe', color: '#2563eb' },
|
||||||
|
'沉默': { bg: '#fffbeb', color: '#d97706' },
|
||||||
|
'企业客户': { bg: '#ecfeff', color: '#0891b2' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerTagStyle(tag: string, catalog: CustomerTag[]) {
|
||||||
|
const meta = catalog.find(item => item.name === tag)
|
||||||
|
return (meta?.color ? customerTagColorMap[meta.color] : undefined)
|
||||||
|
|| customerTagColorMap[tag]
|
||||||
|
|| { bg: '#f1f5f9', color: '#475569' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerTagLabel(tag: string) {
|
||||||
|
return tag === 'VIP客户' ? 'VIP' : tag
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerTagPill({ tag, catalog }: { tag: string; catalog: CustomerTag[] }) {
|
||||||
|
const s = customerTagStyle(tag, catalog)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex max-w-full items-center px-1.5 py-0.5 rounded-md text-[11px] font-medium whitespace-nowrap"
|
||||||
|
style={{ backgroundColor: s.bg, color: s.color }}
|
||||||
|
>
|
||||||
|
<span className="truncate">{customerTagLabel(tag)}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerTagsEditor({
|
||||||
|
tags,
|
||||||
|
catalog,
|
||||||
|
saving,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
tags: string
|
||||||
|
catalog: CustomerTag[]
|
||||||
|
saving?: boolean
|
||||||
|
onChange: (next: string[]) => Promise<void> | void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const currentTags = parseTags(tags)
|
||||||
|
const optionNames = Array.from(new Set([
|
||||||
|
...catalog.map(item => item.name),
|
||||||
|
...currentTags,
|
||||||
|
]))
|
||||||
|
|
||||||
|
const toggleTag = (tag: string) => {
|
||||||
|
if (saving) return
|
||||||
|
const selected = currentTags.includes(tag)
|
||||||
|
if (!selected && currentTags.length >= 10) {
|
||||||
|
antMsg.warning('每位客户最多 10 个标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = selected
|
||||||
|
? currentTags.filter(item => item !== tag)
|
||||||
|
: [...currentTags, tag]
|
||||||
|
void onChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="w-64 max-w-[calc(100vw-32px)]">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-medium text-neutral-600">客户标签</span>
|
||||||
|
<span className="text-[11px] text-neutral-400 tabular-nums">{currentTags.length}/10</span>
|
||||||
|
</div>
|
||||||
|
{optionNames.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed border-neutral-200 px-3 py-4 text-center text-xs text-neutral-400">
|
||||||
|
暂无标签库
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex max-h-56 flex-wrap gap-1.5 overflow-y-auto pr-1">
|
||||||
|
{optionNames.map(name => {
|
||||||
|
const selected = currentTags.includes(name)
|
||||||
|
const s = customerTagStyle(name, catalog)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
disabled={saving}
|
||||||
|
className={`inline-flex max-w-full items-center gap-1 rounded-md border px-2 py-1 text-[12px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
|
||||||
|
selected ? 'shadow-sm ring-1 ring-[#2563eb]/20' : 'hover:bg-neutral-50'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: selected ? s.bg : '#fff',
|
||||||
|
borderColor: selected ? s.bg : '#e5e7eb',
|
||||||
|
color: selected ? s.color : '#4b5563',
|
||||||
|
}}
|
||||||
|
onClick={() => toggleTag(name)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{customerTagLabel(name)}</span>
|
||||||
|
{selected && <CheckCircleOutlined className="shrink-0 text-[11px]" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentTags.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
className="mt-2 rounded px-1.5 py-0.5 text-[11px] text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
onClick={() => { void onChange([]) }}
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomLeft"
|
||||||
|
content={content}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
title="编辑标签"
|
||||||
|
aria-label="编辑客户标签"
|
||||||
|
className="rounded-md -mx-1 px-1 py-1 cursor-pointer outline-none hover:bg-neutral-50 focus:bg-neutral-50 focus:ring-1 focus:ring-[#2563eb]/30"
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
setOpen(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||||
|
<div className="text-[11px] font-semibold text-neutral-500">标签</div>
|
||||||
|
{saving ? <Spin size="small" /> : <TagsOutlined className="text-[12px] text-neutral-400" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{currentTags.length === 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-neutral-400">
|
||||||
|
<PlusOutlined className="text-[10px]" />
|
||||||
|
暂无
|
||||||
|
</span>
|
||||||
|
) : currentTags.map(tag => (
|
||||||
|
<CustomerTagPill key={tag} tag={tag} catalog={catalog} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function channelLabel(source?: string) {
|
function channelLabel(source?: string) {
|
||||||
if (!source) return '网页'
|
if (!source) return '网页'
|
||||||
if (source.includes('微信') || source.toLowerCase().includes('wechat')) return '微信'
|
if (source.includes('微信') || source.toLowerCase().includes('wechat')) return '微信'
|
||||||
@@ -277,11 +440,13 @@ const Dashboard = () => {
|
|||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'waiting' | 'active'>('all')
|
const [statusFilter, setStatusFilter] = useState<'all' | 'waiting' | 'active'>('all')
|
||||||
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
|
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
|
||||||
const [filterOpen, setFilterOpen] = useState(false)
|
const [filterOpen, setFilterOpen] = useState(false)
|
||||||
|
const [tagCatalog, setTagCatalog] = useState<CustomerTag[]>([])
|
||||||
const [visitorTyping, setVisitorTyping] = useState(false)
|
const [visitorTyping, setVisitorTyping] = useState(false)
|
||||||
/** 访客输入框未发送草稿(实时) */
|
/** 访客输入框未发送草稿(实时) */
|
||||||
const [visitorDraft, setVisitorDraft] = useState('')
|
const [visitorDraft, setVisitorDraft] = useState('')
|
||||||
const [customerContacts, setCustomerContacts] = useState<CustomerContact[]>([])
|
const [customerContacts, setCustomerContacts] = useState<CustomerContact[]>([])
|
||||||
const [savingCustomerField, setSavingCustomerField] = useState<CustomerEditableField | null>(null)
|
const [savingCustomerField, setSavingCustomerField] = useState<CustomerEditableField | null>(null)
|
||||||
|
const [savingCustomerTags, setSavingCustomerTags] = useState(false)
|
||||||
/** 驱动在线读秒每秒刷新 */
|
/** 驱动在线读秒每秒刷新 */
|
||||||
const [clockTick, setClockTick] = useState(0)
|
const [clockTick, setClockTick] = useState(0)
|
||||||
const initialLoad = useRef(true)
|
const initialLoad = useRef(true)
|
||||||
@@ -510,6 +675,18 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
useEffect(() => { void loadAll() }, [loadAll])
|
useEffect(() => { void loadAll() }, [loadAll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
getCustomerTags()
|
||||||
|
.then(res => {
|
||||||
|
if (!cancelled) setTagCatalog(Array.isArray(res.data) ? res.data : [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setTagCatalog([])
|
||||||
|
})
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 稳定 WebSocket:仅 token 变化时建连;切会话不重连;断线自动重连(ws / wss)
|
// 稳定 WebSocket:仅 token 变化时建连;切会话不重连;断线自动重连(ws / wss)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.token) return
|
if (!user?.token) return
|
||||||
@@ -801,7 +978,7 @@ const Dashboard = () => {
|
|||||||
setQuickOpen(false)
|
setQuickOpen(false)
|
||||||
closeSuggest()
|
closeSuggest()
|
||||||
try {
|
try {
|
||||||
await useQuickReply(item.id)
|
await recordQuickReplyUsage(item.id)
|
||||||
} catch { /* 计数失败可忽略 */ }
|
} catch { /* 计数失败可忽略 */ }
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = messageInputRef.current
|
const el = messageInputRef.current
|
||||||
@@ -929,6 +1106,39 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const saveCustomerTags = useCallback(async (customerId: number, tags: string[]) => {
|
||||||
|
const normalized = Array.from(new Set(tags.map(t => t.trim()).filter(Boolean))).slice(0, 10)
|
||||||
|
const nextTags = JSON.stringify(normalized)
|
||||||
|
const previous = customers[customerId]
|
||||||
|
setSavingCustomerTags(true)
|
||||||
|
setCustomers(prev => {
|
||||||
|
const current = prev[customerId]
|
||||||
|
if (!current) return prev
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[customerId]: { ...current, tags: nextTags },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const res = await updateCustomer(customerId, { tags: nextTags })
|
||||||
|
const updated = res.data
|
||||||
|
setCustomers(prev => ({
|
||||||
|
...prev,
|
||||||
|
[customerId]: { ...prev[customerId], ...updated },
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
if (previous) {
|
||||||
|
setCustomers(prev => ({
|
||||||
|
...prev,
|
||||||
|
[customerId]: previous,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
antMsg.error(e instanceof Error ? e.message : '保存标签失败')
|
||||||
|
} finally {
|
||||||
|
setSavingCustomerTags(false)
|
||||||
|
}
|
||||||
|
}, [customers])
|
||||||
|
|
||||||
const filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
|
const filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
|
||||||
const filteredSessions = sessions
|
const filteredSessions = sessions
|
||||||
.filter(session => {
|
.filter(session => {
|
||||||
@@ -1846,16 +2056,12 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">标签</div>
|
<CustomerTagsEditor
|
||||||
<div className="flex flex-wrap gap-1">
|
tags={selectedCustomer.tags}
|
||||||
{parseTags(selectedCustomer.tags).length === 0 ? (
|
catalog={tagCatalog}
|
||||||
<span className="text-xs text-neutral-400">暂无</span>
|
saving={savingCustomerTags}
|
||||||
) : parseTags(selectedCustomer.tags).map(tag => (
|
onChange={tags => saveCustomerTags(selectedCustomer.id, tags)}
|
||||||
<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>
|
||||||
|
|
||||||
<div className="mt-4 mb-3">
|
<div className="mt-4 mb-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user