优化工作台客户标签快速选择

This commit is contained in:
yml2213
2026-07-29 09:39:47 +08:00
parent 16d9ab0e5e
commit 523a306d5e
+221 -15
View File
@@ -4,7 +4,7 @@ import {
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
StopOutlined,
StopOutlined, PlusOutlined, TagsOutlined,
} from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview'
@@ -12,9 +12,9 @@ import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
import { useAuth } from '@/stores/auth'
import {
addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply,
type AvailableAgent, type BlacklistDuration, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
getCustomerTags, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply as recordQuickReplyUsage,
type AvailableAgent, type BlacklistDuration, type Customer, type CustomerContact, type CustomerTag, type KnowledgeEntry, type Message, type QuickReply,
type Session, type SessionEvent, type VisitorPageView,
} 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) {
if (!source) return '网页'
if (source.includes('微信') || source.toLowerCase().includes('wechat')) return '微信'
@@ -277,11 +440,13 @@ const Dashboard = () => {
const [statusFilter, setStatusFilter] = useState<'all' | 'waiting' | 'active'>('all')
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
const [filterOpen, setFilterOpen] = useState(false)
const [tagCatalog, setTagCatalog] = useState<CustomerTag[]>([])
const [visitorTyping, setVisitorTyping] = useState(false)
/** 访客输入框未发送草稿(实时) */
const [visitorDraft, setVisitorDraft] = useState('')
const [customerContacts, setCustomerContacts] = useState<CustomerContact[]>([])
const [savingCustomerField, setSavingCustomerField] = useState<CustomerEditableField | null>(null)
const [savingCustomerTags, setSavingCustomerTags] = useState(false)
/** 驱动在线读秒每秒刷新 */
const [clockTick, setClockTick] = useState(0)
const initialLoad = useRef(true)
@@ -510,6 +675,18 @@ const Dashboard = () => {
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)
useEffect(() => {
if (!user?.token) return
@@ -801,7 +978,7 @@ const Dashboard = () => {
setQuickOpen(false)
closeSuggest()
try {
await useQuickReply(item.id)
await recordQuickReplyUsage(item.id)
} catch { /* 计数失败可忽略 */ }
requestAnimationFrame(() => {
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 filteredSessions = sessions
.filter(session => {
@@ -1846,16 +2056,12 @@ const Dashboard = () => {
</div>
<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>
) : parseTags(selectedCustomer.tags).map(tag => (
<span key={tag} className="text-[11px] px-1.5 py-0.5 rounded-md bg-[#dbeafe] text-[#2563eb] font-medium">
{tag}
</span>
))}
</div>
<CustomerTagsEditor
tags={selectedCustomer.tags}
catalog={tagCatalog}
saving={savingCustomerTags}
onChange={tags => saveCustomerTags(selectedCustomer.id, tags)}
/>
</div>
<div className="mt-4 mb-3">