diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index 6168ed5..0617c21 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -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 = { + 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 ( + + {customerTagLabel(tag)} + + ) +} + +function CustomerTagsEditor({ + tags, + catalog, + saving, + onChange, +}: { + tags: string + catalog: CustomerTag[] + saving?: boolean + onChange: (next: string[]) => Promise | 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 = ( +
+
+ 客户标签 + {currentTags.length}/10 +
+ {optionNames.length === 0 ? ( +
+ 暂无标签库 +
+ ) : ( +
+ {optionNames.map(name => { + const selected = currentTags.includes(name) + const s = customerTagStyle(name, catalog) + return ( + + ) + })} +
+ )} + {currentTags.length > 0 && ( + + )} +
+ ) + + return ( + +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setOpen(true) + } + }} + > +
+
标签
+ {saving ? : } +
+
+ {currentTags.length === 0 ? ( + + + 暂无 + + ) : currentTags.map(tag => ( + + ))} +
+
+
+ ) +} + 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([]) const [visitorTyping, setVisitorTyping] = useState(false) /** 访客输入框未发送草稿(实时) */ const [visitorDraft, setVisitorDraft] = useState('') const [customerContacts, setCustomerContacts] = useState([]) const [savingCustomerField, setSavingCustomerField] = useState(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 = () => {
-
标签
-
- {parseTags(selectedCustomer.tags).length === 0 ? ( - 暂无 - ) : parseTags(selectedCustomer.tags).map(tag => ( - - {tag} - - ))} -
+ saveCustomerTags(selectedCustomer.id, tags)} + />