refactor(web): 拆分 Dashboard/Settings/VisitorChat 超大组件为子模块
- Dashboard 2591→1944 行,抽出 dashboard/ 纯函数、客户资料字段与 5 个状态自持弹窗 - Settings 1602→478 行,抽出 settings/ 16 个分区模块(设置卡片、弹窗与工具函数) - VisitorChat 1343→1045 行,抽出 visitor/ 头部、消息区、输入区与评价浮层 - 均为纯代码搬移:行为、路由入口与导出不变;tsc -b 与 vite build 通过
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Button, Checkbox, Dropdown, Input, Modal, Radio, Select, Spin, message as antMsg, Popover } from 'antd'
|
||||
import { Button, Dropdown, Input, Modal, Popover, Spin, message as antMsg } from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
||||
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
|
||||
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
|
||||
StopOutlined, PlusOutlined, TagsOutlined, UserOutlined,
|
||||
StopOutlined, UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
@@ -12,382 +12,19 @@ import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import { OPEN_AGENT_SESSION_EVENT, useAgentNotifications } from '@/stores/notifications'
|
||||
import {
|
||||
addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
||||
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,
|
||||
addSessionNote, claimSession, getCustomer, getCustomerTags, getCustomers, getSession, getSessionMessages, getSessions,
|
||||
getQuickReplies, markSessionRead, sendSessionMessage, suggestQuickReplies, updateCustomer, updateSessionPriority, uploadImage,
|
||||
useQuickReply as recordQuickReplyUsage,
|
||||
type Customer, type CustomerContact, type CustomerTag, type Message, type QuickReply,
|
||||
type Session, type SessionEvent, type VisitorPageView,
|
||||
} from '@/services/api'
|
||||
|
||||
type CustomerEditableField = 'phone' | 'email' | 'wechat' | 'qq'
|
||||
|
||||
/** 客户侧栏基本信息:点击输入,失焦/回车保存 */
|
||||
function CustomerInfoField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
saving?: boolean
|
||||
onSave: (next: string) => Promise<void> | void
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(value)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(value)
|
||||
}, [value, editing])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
})
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const commit = async () => {
|
||||
const next = draft.trim()
|
||||
setEditing(false)
|
||||
if (next === (value || '').trim()) return
|
||||
await onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-h-[28px]">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12 leading-7">{label}</span>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="min-w-0 flex-1 h-7 px-2 rounded border border-[#2563eb] bg-white text-[13px] text-neutral-800 outline-none"
|
||||
value={draft}
|
||||
disabled={saving || disabled}
|
||||
placeholder={placeholder || '点击填写'}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={() => { void commit() }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void commit()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(value)
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || saving}
|
||||
title="点击编辑"
|
||||
onClick={() => !disabled && setEditing(true)}
|
||||
className={`min-w-0 flex-1 text-left h-7 px-1 -mx-1 rounded truncate text-[13px] leading-7 ${
|
||||
value
|
||||
? 'text-neutral-800 hover:bg-neutral-100'
|
||||
: 'text-neutral-400 hover:bg-neutral-100'
|
||||
} disabled:cursor-default disabled:hover:bg-transparent`}
|
||||
>
|
||||
{value || placeholder || '点击填写'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 按 id 合并消息,再按 seq / id 排序 */
|
||||
function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] {
|
||||
const map = new Map<number, Message>()
|
||||
for (const item of existing) map.set(item.id, item)
|
||||
for (const item of incoming) map.set(item.id, item)
|
||||
return Array.from(map.values()).sort((a, b) => {
|
||||
const seqDiff = (a.seq || 0) - (b.seq || 0)
|
||||
return seqDiff !== 0 ? seqDiff : a.id - b.id
|
||||
})
|
||||
}
|
||||
|
||||
function maxMessageSeq(messages: Message[]): number {
|
||||
return messages.reduce((acc, item) => Math.max(acc, item.seq || 0), 0)
|
||||
}
|
||||
|
||||
function normalizeMessage(raw: Partial<Message> & { id?: number }, sessionId: number): Message | null {
|
||||
if (!raw?.id) return null
|
||||
return {
|
||||
id: Number(raw.id),
|
||||
session_id: Number(raw.session_id ?? sessionId),
|
||||
sender_type: raw.sender_type === 'agent' ? 'agent' : 'visitor',
|
||||
sender_id: raw.sender_id ?? null,
|
||||
content: String(raw.content ?? ''),
|
||||
type: raw.type === 'image' ? 'image' : 'text',
|
||||
seq: Number(raw.seq ?? 0),
|
||||
sent_at: raw.sent_at || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const endReasons = [
|
||||
{ value: 'resolved', label: '已解决' },
|
||||
{ value: 'no_response', label: '无人回复' },
|
||||
{ value: 'visitor_left', label: '访客离开' },
|
||||
{ value: 'transferred', label: '已转接' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
/** 列表项展示:紧急 / 等待中 / 进行中 */
|
||||
function listStatusMeta(session: Session) {
|
||||
if (session.priority === 'urgent') {
|
||||
return { label: '紧急', bar: '#dc2626', avatarBg: '#fef2f2', avatarColor: '#dc2626', dot: '#dc2626' }
|
||||
}
|
||||
if (session.status === 'waiting') {
|
||||
return { label: '等待中', bar: '#d97706', avatarBg: '#fffbeb', avatarColor: '#d97706', dot: '#d97706' }
|
||||
}
|
||||
return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' }
|
||||
}
|
||||
|
||||
/** 坐席在线状态(转接列表) */
|
||||
function agentStatusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return { text: '在线', dot: '#16a34a', bg: '#ecfdf5', color: '#15803d' }
|
||||
case 'busy':
|
||||
return { text: '忙碌', dot: '#d97706', bg: '#fffbeb', color: '#b45309' }
|
||||
case 'disabled':
|
||||
return { text: '停用', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' }
|
||||
default:
|
||||
return { text: '离线', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' }
|
||||
}
|
||||
}
|
||||
|
||||
function relativeTime(iso?: string | null) {
|
||||
if (!iso) return ''
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return '刚刚'
|
||||
if (mins < 60) return `${mins}分钟前`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
function parseTags(tagsStr: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(tagsStr)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : []
|
||||
}
|
||||
}
|
||||
|
||||
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 '微信'
|
||||
if (source.toLowerCase().includes('app')) return 'APP'
|
||||
if (source.includes('电话')) return '电话'
|
||||
return source.length > 6 ? '网页' : source
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
messages: Message[]
|
||||
events: SessionEvent[]
|
||||
pageViews: VisitorPageView[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string {
|
||||
if (!startIso) return '—'
|
||||
const start = new Date(startIso).getTime()
|
||||
if (!Number.isFinite(start)) return '—'
|
||||
const end = endIso ? new Date(endIso).getTime() : (nowMs ?? Date.now())
|
||||
let sec = Math.max(0, Math.floor((end - start) / 1000))
|
||||
const h = Math.floor(sec / 3600)
|
||||
sec %= 3600
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function shortPagePath(url?: string): string {
|
||||
if (!url) return '—'
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const path = u.pathname + (u.search || '') + (u.hash || '')
|
||||
return path.length > 56 ? `${path.slice(0, 54)}…` : path || '/'
|
||||
} catch {
|
||||
return url.length > 56 ? `${url.slice(0, 54)}…` : url
|
||||
}
|
||||
}
|
||||
import {
|
||||
channelLabel, formatOnlineDuration, listStatusMeta, maxMessageSeq, mergeMessagesBySeq, normalizeMessage,
|
||||
relativeTime, shortPagePath,
|
||||
type CustomerEditableField, type SessionDetail,
|
||||
} from './dashboard/helpers'
|
||||
import { CustomerInfoField, CustomerTagsEditor } from './dashboard/CustomerFields'
|
||||
import { BlacklistModal, EndSessionModal, KnowledgePickerModal, QuickReplyPickerModal, TransferModal } from './dashboard/modals'
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user } = useAuth()
|
||||
@@ -402,24 +39,10 @@ const Dashboard = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [transferOpen, setTransferOpen] = useState(false)
|
||||
const [availableAgents, setAvailableAgents] = useState<AvailableAgent[]>([])
|
||||
const [targetAgentID, setTargetAgentID] = useState<number>()
|
||||
const [blacklistOpen, setBlacklistOpen] = useState(false)
|
||||
const [blacklistKind, setBlacklistKind] = useState<'ip' | 'device'>('ip')
|
||||
const [blacklistDuration, setBlacklistDuration] = useState<BlacklistDuration>('7d')
|
||||
const [blacklistReason, setBlacklistReason] = useState('')
|
||||
const [blacklistEndSession, setBlacklistEndSession] = useState(true)
|
||||
const [blacklistSaving, setBlacklistSaving] = useState(false)
|
||||
const [endingOpen, setEndingOpen] = useState(false)
|
||||
const [endReason, setEndReason] = useState('resolved')
|
||||
const [knowledgeOpen, setKnowledgeOpen] = useState(false)
|
||||
const [knowledgeKeyword, setKnowledgeKeyword] = useState('')
|
||||
const [knowledgeEntries, setKnowledgeEntries] = useState<KnowledgeEntry[]>([])
|
||||
const [knowledgeLoading, setKnowledgeLoading] = useState(false)
|
||||
const [quickOpen, setQuickOpen] = useState(false)
|
||||
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('')
|
||||
@@ -889,26 +512,6 @@ const Dashboard = () => {
|
||||
if (selectedId) loadDetail(selectedId)
|
||||
}, [selectedId, loadDetail])
|
||||
|
||||
useEffect(() => {
|
||||
if (!knowledgeOpen) return
|
||||
setKnowledgeLoading(true)
|
||||
getKnowledgeEntries({ search: knowledgeKeyword, page: 1 }).then(response => {
|
||||
setKnowledgeEntries(Array.isArray(response.list) ? response.list : [])
|
||||
}).catch(() => setKnowledgeEntries([])).finally(() => setKnowledgeLoading(false))
|
||||
}, [knowledgeOpen, knowledgeKeyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickOpen) return
|
||||
setQuickLoading(true)
|
||||
getQuickReplies({
|
||||
scope: 'all',
|
||||
q: quickKeyword.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}).then(response => {
|
||||
setQuickList(Array.isArray(response.list) ? response.list : [])
|
||||
}).catch(() => setQuickList([])).finally(() => setQuickLoading(false))
|
||||
}, [quickOpen, quickKeyword])
|
||||
|
||||
// 右侧栏快捷回复列表(始终可用)
|
||||
useEffect(() => {
|
||||
@@ -1312,85 +915,30 @@ const Dashboard = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const openTransfer = async () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
// 默认返回在线/忙碌坐席(含 status)
|
||||
const response = await getAvailableAgents()
|
||||
setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id))
|
||||
setTargetAgentID(undefined)
|
||||
setTransferOpen(true)
|
||||
} catch {
|
||||
antMsg.error('加载可转接坐席失败')
|
||||
}
|
||||
const clearAndReload = async () => {
|
||||
setSelectedId(null)
|
||||
setDetail(null)
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!selected || !targetAgentID) return
|
||||
const target = availableAgents.find(a => a.id === targetAgentID)
|
||||
if (target && target.status !== 'online' && target.status !== 'busy') {
|
||||
antMsg.warning('只能转接给在线或忙碌的坐席')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await transferSession(selected.id, targetAgentID)
|
||||
antMsg.success('会话已转接')
|
||||
setTransferOpen(false)
|
||||
setSelectedId(null)
|
||||
setDetail(null)
|
||||
await loadAll()
|
||||
} catch (error) {
|
||||
antMsg.error(error instanceof Error ? error.message : '转接失败')
|
||||
}
|
||||
const openTransfer = () => {
|
||||
if (!selected) return
|
||||
setTransferOpen(true)
|
||||
}
|
||||
|
||||
const openBlacklist = () => {
|
||||
if (!selected) return
|
||||
setBlacklistKind(selected.visitor_ip ? 'ip' : 'device')
|
||||
setBlacklistDuration('7d')
|
||||
setBlacklistReason('')
|
||||
setBlacklistEndSession(selected.status === 'active' || selected.status === 'waiting')
|
||||
setBlacklistOpen(true)
|
||||
}
|
||||
|
||||
const handleBlacklist = async () => {
|
||||
if (!selected) return
|
||||
const reason = blacklistReason.trim()
|
||||
if (!reason) {
|
||||
antMsg.warning('请填写拉黑原因')
|
||||
return
|
||||
}
|
||||
if (blacklistKind === 'ip' && !selected.visitor_ip) {
|
||||
antMsg.warning('该会话无有效 IP,请改选「设备」')
|
||||
return
|
||||
}
|
||||
if (blacklistKind === 'device' && !selected.device_key && !selected.user_agent) {
|
||||
antMsg.warning('该会话无设备标识,请改选「IP」')
|
||||
return
|
||||
}
|
||||
setBlacklistSaving(true)
|
||||
try {
|
||||
await createBlacklist({
|
||||
session_id: selected.id,
|
||||
kind: blacklistKind,
|
||||
duration: blacklistDuration,
|
||||
reason,
|
||||
end_session: blacklistEndSession,
|
||||
})
|
||||
antMsg.success(blacklistKind === 'ip' ? '已拉黑该 IP' : '已拉黑该设备')
|
||||
setBlacklistOpen(false)
|
||||
if (blacklistEndSession) {
|
||||
setSelectedId(null)
|
||||
setDetail(null)
|
||||
} else if (selectedId) {
|
||||
await loadDetail(selectedId, false)
|
||||
}
|
||||
await loadAll()
|
||||
} catch (error) {
|
||||
antMsg.error(error instanceof Error ? error.message : '拉黑失败')
|
||||
} finally {
|
||||
setBlacklistSaving(false)
|
||||
const handleBlacklistDone = async (ended: boolean) => {
|
||||
if (ended) {
|
||||
setSelectedId(null)
|
||||
setDetail(null)
|
||||
} else if (selectedId) {
|
||||
await loadDetail(selectedId, false)
|
||||
}
|
||||
await loadAll()
|
||||
}
|
||||
|
||||
const handlePriority = async (priority: 'normal' | 'urgent') => {
|
||||
@@ -1404,19 +952,6 @@ const Dashboard = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnd = async () => {
|
||||
if (!selected) return
|
||||
try {
|
||||
await endSession(selected.id, endReason)
|
||||
antMsg.success('会话已结束')
|
||||
setEndingOpen(false)
|
||||
setSelectedId(null)
|
||||
setDetail(null)
|
||||
await loadAll()
|
||||
} catch (error) {
|
||||
antMsg.error(error instanceof Error ? error.message : '结束会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddNote = async () => {
|
||||
if (!selected || !noteInput.trim()) return
|
||||
@@ -2357,150 +1892,25 @@ const Dashboard = () => {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<Modal
|
||||
title="转接会话"
|
||||
<TransferModal
|
||||
open={transferOpen}
|
||||
onCancel={() => setTransferOpen(false)}
|
||||
onOk={handleTransfer}
|
||||
okButtonProps={{
|
||||
disabled: !targetAgentID || (() => {
|
||||
const t = availableAgents.find(a => a.id === targetAgentID)
|
||||
return Boolean(t && t.status !== 'online' && t.status !== 'busy')
|
||||
})(),
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择接手坐席(绿色在线 / 橙色忙碌)。</p>
|
||||
{availableAgents.length === 0 ? (
|
||||
<div className="text-sm text-neutral-400 py-6 text-center border border-dashed border-neutral-200 rounded-lg">
|
||||
当前没有其他可转接坐席
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
className="w-full"
|
||||
placeholder="选择坐席"
|
||||
value={targetAgentID}
|
||||
onChange={setTargetAgentID}
|
||||
optionLabelProp="label"
|
||||
options={availableAgents.map(agent => {
|
||||
const st = agentStatusMeta(agent.status)
|
||||
return {
|
||||
value: agent.id,
|
||||
// 选中后输入框展示:昵称 + 状态
|
||||
label: `${agent.nickname}(${st.text})`,
|
||||
disabled: agent.status !== 'online' && agent.status !== 'busy',
|
||||
}
|
||||
})}
|
||||
optionRender={option => {
|
||||
const agent = availableAgents.find(a => a.id === option.value)
|
||||
const st = agentStatusMeta(agent?.status || 'offline')
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5 min-w-0">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: st.dot }}
|
||||
title={st.text}
|
||||
/>
|
||||
<span className="truncate text-neutral-800 flex-1 min-w-0">
|
||||
{agent?.nickname || option.label}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[11px] px-1.5 py-0 rounded font-medium"
|
||||
style={{ background: st.bg, color: st.color }}
|
||||
>
|
||||
{st.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal title="结束会话" open={endingOpen} onCancel={() => setEndingOpen(false)} onOk={handleEnd} okText="确认结束">
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择本次会话的结束原因。</p>
|
||||
<Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title="拉黑访客"
|
||||
session={selected}
|
||||
excludeAgentId={user?.user_id}
|
||||
onClose={() => setTransferOpen(false)}
|
||||
onDone={clearAndReload}
|
||||
/>
|
||||
<EndSessionModal
|
||||
open={endingOpen}
|
||||
session={selected}
|
||||
onClose={() => setEndingOpen(false)}
|
||||
onDone={clearAndReload}
|
||||
/>
|
||||
<BlacklistModal
|
||||
open={blacklistOpen}
|
||||
onCancel={() => setBlacklistOpen(false)}
|
||||
onOk={handleBlacklist}
|
||||
okText="确认拉黑"
|
||||
okButtonProps={{ danger: true, loading: blacklistSaving, disabled: !blacklistReason.trim() }}
|
||||
cancelButtonProps={{ disabled: blacklistSaving }}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="flex flex-col gap-3.5 pt-1">
|
||||
<div>
|
||||
<div className="text-sm text-neutral-600 mb-2">拉黑类型</div>
|
||||
<Radio.Group
|
||||
value={blacklistKind}
|
||||
onChange={e => setBlacklistKind(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{
|
||||
value: 'ip',
|
||||
label: selected?.visitor_ip ? `IP(${selected.visitor_ip})` : 'IP(无)',
|
||||
disabled: !selected?.visitor_ip,
|
||||
},
|
||||
{
|
||||
value: 'device',
|
||||
label: selected?.device_key || selected?.user_agent
|
||||
? '设备'
|
||||
: '设备(无)',
|
||||
disabled: !selected?.device_key && !selected?.user_agent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{blacklistKind === 'device' && (
|
||||
<div className="mt-1.5 text-xs text-neutral-400 truncate" title={selected?.device_key || selected?.user_agent}>
|
||||
{selected?.device_key
|
||||
? `设备标识:${selected.device_key.slice(0, 12)}…`
|
||||
: selected?.user_agent
|
||||
? `将按浏览器指纹:${selected.user_agent.slice(0, 48)}…`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-neutral-600 mb-2">释放时间</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={blacklistDuration}
|
||||
onChange={v => setBlacklistDuration(v)}
|
||||
options={[
|
||||
{ value: '1h', label: '1 小时后自动解除' },
|
||||
{ value: '6h', label: '6 小时后自动解除' },
|
||||
{ value: '1d', label: '1 天后自动解除' },
|
||||
{ value: '7d', label: '7 天后自动解除' },
|
||||
{ value: '30d', label: '30 天后自动解除' },
|
||||
{ value: 'permanent', label: '长期(不自动解除)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-neutral-600 mb-2">拉黑原因 <span className="text-red-500">*</span></div>
|
||||
<Input.TextArea
|
||||
value={blacklistReason}
|
||||
onChange={e => setBlacklistReason(e.target.value)}
|
||||
placeholder="例如:恶意骚扰、发送垃圾信息…"
|
||||
maxLength={200}
|
||||
showCount
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={blacklistEndSession}
|
||||
onChange={e => setBlacklistEndSession(e.target.checked)}
|
||||
disabled={selected?.status === 'ended' || selected?.status === 'archived'}
|
||||
>
|
||||
拉黑后同时结束当前会话
|
||||
</Checkbox>
|
||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-md px-2.5 py-2 leading-relaxed">
|
||||
拉黑后,该{blacklistKind === 'ip' ? ' IP ' : '设备'}再次访问在线客服将被拦截,直至到期或手动解除。
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
session={selected}
|
||||
onClose={() => setBlacklistOpen(false)}
|
||||
onDone={handleBlacklistDone}
|
||||
/>
|
||||
<Modal
|
||||
title="图片预览"
|
||||
open={Boolean(pendingImage)}
|
||||
@@ -2517,73 +1927,16 @@ const Dashboard = () => {
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 text-center mt-3 mb-0">将自动压缩并转为 WebP 后上传</p>
|
||||
</Modal>
|
||||
<Modal title="知识库" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索标题或内容"
|
||||
value={knowledgeKeyword}
|
||||
onChange={event => setKnowledgeKeyword(event.target.value)}
|
||||
allowClear
|
||||
className="mb-3"
|
||||
/>
|
||||
{knowledgeLoading ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-auto">
|
||||
{knowledgeEntries.length === 0 ? (
|
||||
<div className="text-center text-neutral-400 py-8">未找到可用知识条目</div>
|
||||
) : knowledgeEntries.map(entry => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="w-full text-left border border-neutral-100 rounded-lg p-3 hover:border-blue-300 hover:bg-blue-50"
|
||||
onClick={() => { setMessageInput(entry.content); setKnowledgeOpen(false) }}
|
||||
>
|
||||
<div className="text-sm font-medium text-neutral-800">{entry.title}</div>
|
||||
<div className="text-xs text-neutral-500 mt-1 line-clamp-2">{entry.content}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal title="快捷回复" open={quickOpen} onCancel={() => setQuickOpen(false)} footer={null} width={640}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索标题、内容或输入码"
|
||||
value={quickKeyword}
|
||||
onChange={event => setQuickKeyword(event.target.value)}
|
||||
allowClear
|
||||
className="mb-3"
|
||||
/>
|
||||
<p className="text-xs text-neutral-400 mb-2">含团队已发布 + 我的话术。管理请到侧栏「快捷回复」。</p>
|
||||
{quickLoading ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-auto">
|
||||
{quickList.length === 0 ? (
|
||||
<div className="text-center text-neutral-400 py-8">暂无快捷回复</div>
|
||||
) : quickList.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="w-full text-left border border-neutral-100 rounded-lg p-3 hover:border-blue-300 hover:bg-blue-50"
|
||||
onClick={() => void applyQuickReply(item)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-800">{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded">/{item.shortcut}</code>
|
||||
)}
|
||||
<span className="text-[10px] text-neutral-400 ml-auto">
|
||||
{item.scope === 'team' ? '团队' : '个人'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 mt-1 line-clamp-2">{item.content}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
<KnowledgePickerModal
|
||||
open={knowledgeOpen}
|
||||
onClose={() => setKnowledgeOpen(false)}
|
||||
onPick={content => setMessageInput(content)}
|
||||
/>
|
||||
<QuickReplyPickerModal
|
||||
open={quickOpen}
|
||||
onClose={() => setQuickOpen(false)}
|
||||
onApply={item => { void applyQuickReply(item) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+102
-1226
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Popover, Spin, message as antMsg } from 'antd'
|
||||
import { CheckCircleOutlined, PlusOutlined, TagsOutlined } from '@ant-design/icons'
|
||||
import type { CustomerTag } from '@/services/api'
|
||||
import { customerTagLabel, customerTagStyle, parseTags } from './helpers'
|
||||
|
||||
/** 客户侧栏基本信息:点击输入,失焦/回车保存 */
|
||||
export function CustomerInfoField({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
disabled,
|
||||
saving,
|
||||
onSave,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
saving?: boolean
|
||||
onSave: (next: string) => Promise<void> | void
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(value)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!editing) setDraft(value)
|
||||
}, [value, editing])
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
})
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const commit = async () => {
|
||||
const next = draft.trim()
|
||||
setEditing(false)
|
||||
if (next === (value || '').trim()) return
|
||||
await onSave(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-h-[28px]">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12 leading-7">{label}</span>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="min-w-0 flex-1 h-7 px-2 rounded border border-[#2563eb] bg-white text-[13px] text-neutral-800 outline-none"
|
||||
value={draft}
|
||||
disabled={saving || disabled}
|
||||
placeholder={placeholder || '点击填写'}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={() => { void commit() }}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void commit()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setDraft(value)
|
||||
setEditing(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || saving}
|
||||
title="点击编辑"
|
||||
onClick={() => !disabled && setEditing(true)}
|
||||
className={`min-w-0 flex-1 text-left h-7 px-1 -mx-1 rounded truncate text-[13px] leading-7 ${
|
||||
value
|
||||
? 'text-neutral-800 hover:bg-neutral-100'
|
||||
: 'text-neutral-400 hover:bg-neutral-100'
|
||||
} disabled:cursor-default disabled:hover:bg-transparent`}
|
||||
>
|
||||
{value || placeholder || '点击填写'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { CustomerTag, Message, Session, SessionEvent, VisitorPageView } from '@/services/api'
|
||||
|
||||
export type CustomerEditableField = 'phone' | 'email' | 'wechat' | 'qq'
|
||||
|
||||
/** 按 id 合并消息,再按 seq / id 排序 */
|
||||
export function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] {
|
||||
const map = new Map<number, Message>()
|
||||
for (const item of existing) map.set(item.id, item)
|
||||
for (const item of incoming) map.set(item.id, item)
|
||||
return Array.from(map.values()).sort((a, b) => {
|
||||
const seqDiff = (a.seq || 0) - (b.seq || 0)
|
||||
return seqDiff !== 0 ? seqDiff : a.id - b.id
|
||||
})
|
||||
}
|
||||
|
||||
export function maxMessageSeq(messages: Message[]): number {
|
||||
return messages.reduce((acc, item) => Math.max(acc, item.seq || 0), 0)
|
||||
}
|
||||
|
||||
export function normalizeMessage(raw: Partial<Message> & { id?: number }, sessionId: number): Message | null {
|
||||
if (!raw?.id) return null
|
||||
return {
|
||||
id: Number(raw.id),
|
||||
session_id: Number(raw.session_id ?? sessionId),
|
||||
sender_type: raw.sender_type === 'agent' ? 'agent' : 'visitor',
|
||||
sender_id: raw.sender_id ?? null,
|
||||
content: String(raw.content ?? ''),
|
||||
type: raw.type === 'image' ? 'image' : 'text',
|
||||
seq: Number(raw.seq ?? 0),
|
||||
sent_at: raw.sent_at || new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export const endReasons = [
|
||||
{ value: 'resolved', label: '已解决' },
|
||||
{ value: 'no_response', label: '无人回复' },
|
||||
{ value: 'visitor_left', label: '访客离开' },
|
||||
{ value: 'transferred', label: '已转接' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
/** 列表项展示:紧急 / 等待中 / 进行中 */
|
||||
export function listStatusMeta(session: Session) {
|
||||
if (session.priority === 'urgent') {
|
||||
return { label: '紧急', bar: '#dc2626', avatarBg: '#fef2f2', avatarColor: '#dc2626', dot: '#dc2626' }
|
||||
}
|
||||
if (session.status === 'waiting') {
|
||||
return { label: '等待中', bar: '#d97706', avatarBg: '#fffbeb', avatarColor: '#d97706', dot: '#d97706' }
|
||||
}
|
||||
return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' }
|
||||
}
|
||||
|
||||
/** 坐席在线状态(转接列表) */
|
||||
export function agentStatusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return { text: '在线', dot: '#16a34a', bg: '#ecfdf5', color: '#15803d' }
|
||||
case 'busy':
|
||||
return { text: '忙碌', dot: '#d97706', bg: '#fffbeb', color: '#b45309' }
|
||||
case 'disabled':
|
||||
return { text: '停用', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' }
|
||||
default:
|
||||
return { text: '离线', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' }
|
||||
}
|
||||
}
|
||||
|
||||
export function relativeTime(iso?: string | null) {
|
||||
if (!iso) return ''
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return '刚刚'
|
||||
if (mins < 60) return `${mins}分钟前`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}小时前`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}天前`
|
||||
return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
|
||||
}
|
||||
|
||||
export function parseTags(tagsStr: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(tagsStr)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : []
|
||||
}
|
||||
}
|
||||
|
||||
export 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' },
|
||||
}
|
||||
|
||||
export 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' }
|
||||
}
|
||||
|
||||
export function customerTagLabel(tag: string) {
|
||||
return tag === 'VIP客户' ? 'VIP' : tag
|
||||
}
|
||||
|
||||
export function channelLabel(source?: string) {
|
||||
if (!source) return '网页'
|
||||
if (source.includes('微信') || source.toLowerCase().includes('wechat')) return '微信'
|
||||
if (source.toLowerCase().includes('app')) return 'APP'
|
||||
if (source.includes('电话')) return '电话'
|
||||
return source.length > 6 ? '网页' : source
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
messages: Message[]
|
||||
events: SessionEvent[]
|
||||
pageViews: VisitorPageView[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
export function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string {
|
||||
if (!startIso) return '—'
|
||||
const start = new Date(startIso).getTime()
|
||||
if (!Number.isFinite(start)) return '—'
|
||||
const end = endIso ? new Date(endIso).getTime() : (nowMs ?? Date.now())
|
||||
let sec = Math.max(0, Math.floor((end - start) / 1000))
|
||||
const h = Math.floor(sec / 3600)
|
||||
sec %= 3600
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function shortPagePath(url?: string): string {
|
||||
if (!url) return '—'
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const path = u.pathname + (u.search || '') + (u.hash || '')
|
||||
return path.length > 56 ? `${path.slice(0, 54)}…` : path || '/'
|
||||
} catch {
|
||||
return url.length > 56 ? `${url.slice(0, 54)}…` : url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Checkbox, Input, Modal, Radio, Select, Spin, message as antMsg } from 'antd'
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
createBlacklist, endSession as endSessionApi, getAvailableAgents, getKnowledgeEntries, getQuickReplies, transferSession,
|
||||
type BlacklistDuration, type KnowledgeEntry, type QuickReply, type Session,
|
||||
} from '@/services/api'
|
||||
import { agentStatusMeta, endReasons } from './helpers'
|
||||
|
||||
/** 转接会话弹窗:坐席列表与目标选择状态内聚 */
|
||||
export function TransferModal({
|
||||
open,
|
||||
session,
|
||||
excludeAgentId,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean
|
||||
session: Session | undefined
|
||||
excludeAgentId?: number
|
||||
onClose: () => void
|
||||
onDone: () => Promise<void> | void
|
||||
}) {
|
||||
const [availableAgents, setAvailableAgents] = useState<Awaited<ReturnType<typeof getAvailableAgents>>['data']>([])
|
||||
const [targetAgentID, setTargetAgentID] = useState<number>()
|
||||
const onCloseRef = useRef(onClose)
|
||||
onCloseRef.current = onClose
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
let cancelled = false
|
||||
// 默认返回在线/忙碌坐席(含 status)
|
||||
getAvailableAgents().then(response => {
|
||||
if (cancelled) return
|
||||
setAvailableAgents((response.data || []).filter(agent => agent.id !== excludeAgentId))
|
||||
setTargetAgentID(undefined)
|
||||
}).catch(() => {
|
||||
if (cancelled) return
|
||||
antMsg.error('加载可转接坐席失败')
|
||||
onCloseRef.current()
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open, excludeAgentId])
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!session || !targetAgentID) return
|
||||
const target = (availableAgents || []).find(a => a.id === targetAgentID)
|
||||
if (target && target.status !== 'online' && target.status !== 'busy') {
|
||||
antMsg.warning('只能转接给在线或忙碌的坐席')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await transferSession(session.id, targetAgentID)
|
||||
antMsg.success('会话已转接')
|
||||
await onDone()
|
||||
onClose()
|
||||
} catch (error) {
|
||||
antMsg.error(error instanceof Error ? error.message : '转接失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="转接会话"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleTransfer}
|
||||
okButtonProps={{
|
||||
disabled: !targetAgentID || (() => {
|
||||
const t = (availableAgents || []).find(a => a.id === targetAgentID)
|
||||
return Boolean(t && t.status !== 'online' && t.status !== 'busy')
|
||||
})(),
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择接手坐席(绿色在线 / 橙色忙碌)。</p>
|
||||
{(availableAgents || []).length === 0 ? (
|
||||
<div className="text-sm text-neutral-400 py-6 text-center border border-dashed border-neutral-200 rounded-lg">
|
||||
当前没有其他可转接坐席
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
className="w-full"
|
||||
placeholder="选择坐席"
|
||||
value={targetAgentID}
|
||||
onChange={setTargetAgentID}
|
||||
optionLabelProp="label"
|
||||
options={(availableAgents || []).map(agent => {
|
||||
const st = agentStatusMeta(agent.status)
|
||||
return {
|
||||
value: agent.id,
|
||||
// 选中后输入框展示:昵称 + 状态
|
||||
label: `${agent.nickname}(${st.text})`,
|
||||
disabled: agent.status !== 'online' && agent.status !== 'busy',
|
||||
}
|
||||
})}
|
||||
optionRender={option => {
|
||||
const agent = (availableAgents || []).find(a => a.id === option.value)
|
||||
const st = agentStatusMeta(agent?.status || 'offline')
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5 min-w-0">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full shrink-0"
|
||||
style={{ background: st.dot }}
|
||||
title={st.text}
|
||||
/>
|
||||
<span className="truncate text-neutral-800 flex-1 min-w-0">
|
||||
{agent?.nickname || option.label}
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 text-[11px] px-1.5 py-0 rounded font-medium"
|
||||
style={{ background: st.bg, color: st.color }}
|
||||
>
|
||||
{st.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 结束会话弹窗:结束原因状态内聚 */
|
||||
export function EndSessionModal({
|
||||
open,
|
||||
session,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean
|
||||
session: Session | undefined
|
||||
onClose: () => void
|
||||
onDone: () => Promise<void> | void
|
||||
}) {
|
||||
const [endReason, setEndReason] = useState('resolved')
|
||||
|
||||
const handleEnd = async () => {
|
||||
if (!session) return
|
||||
try {
|
||||
await endSessionApi(session.id, endReason)
|
||||
antMsg.success('会话已结束')
|
||||
await onDone()
|
||||
onClose()
|
||||
} catch (error) {
|
||||
antMsg.error(error instanceof Error ? error.message : '结束会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="结束会话" open={open} onCancel={onClose} onOk={handleEnd} okText="确认结束">
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择本次会话的结束原因。</p>
|
||||
<Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 拉黑访客弹窗:拉黑表单状态内聚,打开时按会话重置默认值 */
|
||||
export function BlacklistModal({
|
||||
open,
|
||||
session,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean
|
||||
session: Session | undefined
|
||||
onClose: () => void
|
||||
/** ended=true 表示已同时结束会话(父层清空选中),false 表示仅拉黑(刷新详情) */
|
||||
onDone: (ended: boolean) => Promise<void> | void
|
||||
}) {
|
||||
const [kind, setKind] = useState<'ip' | 'device'>('ip')
|
||||
const [duration, setDuration] = useState<BlacklistDuration>('7d')
|
||||
const [reason, setReason] = useState('')
|
||||
const [endSessionChecked, setEndSessionChecked] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !session) return
|
||||
setKind(session.visitor_ip ? 'ip' : 'device')
|
||||
setDuration('7d')
|
||||
setReason('')
|
||||
setEndSessionChecked(session.status === 'active' || session.status === 'waiting')
|
||||
}, [open, session])
|
||||
|
||||
const handleBlacklist = async () => {
|
||||
if (!session) return
|
||||
const trimmedReason = reason.trim()
|
||||
if (!trimmedReason) {
|
||||
antMsg.warning('请填写拉黑原因')
|
||||
return
|
||||
}
|
||||
if (kind === 'ip' && !session.visitor_ip) {
|
||||
antMsg.warning('该会话无有效 IP,请改选「设备」')
|
||||
return
|
||||
}
|
||||
if (kind === 'device' && !session.device_key && !session.user_agent) {
|
||||
antMsg.warning('该会话无设备标识,请改选「IP」')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await createBlacklist({
|
||||
session_id: session.id,
|
||||
kind,
|
||||
duration,
|
||||
reason: trimmedReason,
|
||||
end_session: endSessionChecked,
|
||||
})
|
||||
antMsg.success(kind === 'ip' ? '已拉黑该 IP' : '已拉黑该设备')
|
||||
onClose()
|
||||
await onDone(endSessionChecked)
|
||||
} catch (error) {
|
||||
antMsg.error(error instanceof Error ? error.message : '拉黑失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="拉黑访客"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleBlacklist}
|
||||
okText="确认拉黑"
|
||||
okButtonProps={{ danger: true, loading: saving, disabled: !reason.trim() }}
|
||||
cancelButtonProps={{ disabled: saving }}
|
||||
destroyOnClose
|
||||
>
|
||||
<div className="flex flex-col gap-3.5 pt-1">
|
||||
<div>
|
||||
<div className="text-sm text-neutral-600 mb-2">拉黑类型</div>
|
||||
<Radio.Group
|
||||
value={kind}
|
||||
onChange={e => setKind(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{
|
||||
value: 'ip',
|
||||
label: session?.visitor_ip ? `IP(${session.visitor_ip})` : 'IP(无)',
|
||||
disabled: !session?.visitor_ip,
|
||||
},
|
||||
{
|
||||
value: 'device',
|
||||
label: session?.device_key || session?.user_agent
|
||||
? '设备'
|
||||
: '设备(无)',
|
||||
disabled: !session?.device_key && !session?.user_agent,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{kind === 'device' && (
|
||||
<div className="mt-1.5 text-xs text-neutral-400 truncate" title={session?.device_key || session?.user_agent}>
|
||||
{session?.device_key
|
||||
? `设备标识:${session.device_key.slice(0, 12)}…`
|
||||
: session?.user_agent
|
||||
? `将按浏览器指纹:${session.user_agent.slice(0, 48)}…`
|
||||
: ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-neutral-600 mb-2">释放时间</div>
|
||||
<Select
|
||||
className="w-full"
|
||||
value={duration}
|
||||
onChange={v => setDuration(v)}
|
||||
options={[
|
||||
{ value: '1h', label: '1 小时后自动解除' },
|
||||
{ value: '6h', label: '6 小时后自动解除' },
|
||||
{ value: '1d', label: '1 天后自动解除' },
|
||||
{ value: '7d', label: '7 天后自动解除' },
|
||||
{ value: '30d', label: '30 天后自动解除' },
|
||||
{ value: 'permanent', label: '长期(不自动解除)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-neutral-600 mb-2">拉黑原因 <span className="text-red-500">*</span></div>
|
||||
<Input.TextArea
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="例如:恶意骚扰、发送垃圾信息…"
|
||||
maxLength={200}
|
||||
showCount
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={endSessionChecked}
|
||||
onChange={e => setEndSessionChecked(e.target.checked)}
|
||||
disabled={session?.status === 'ended' || session?.status === 'archived'}
|
||||
>
|
||||
拉黑后同时结束当前会话
|
||||
</Checkbox>
|
||||
<div className="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-md px-2.5 py-2 leading-relaxed">
|
||||
拉黑后,该{kind === 'ip' ? ' IP ' : '设备'}再次访问在线客服将被拦截,直至到期或手动解除。
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 知识库选择弹窗:搜索与列表状态内聚 */
|
||||
export function KnowledgePickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onPick,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onPick: (content: string) => void
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [entries, setEntries] = useState<KnowledgeEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getKnowledgeEntries({ search: keyword, page: 1 }).then(response => {
|
||||
setEntries(Array.isArray(response.list) ? response.list : [])
|
||||
}).catch(() => setEntries([])).finally(() => setLoading(false))
|
||||
}, [open, keyword])
|
||||
|
||||
return (
|
||||
<Modal title="知识库" open={open} onCancel={onClose} footer={null} width={640}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索标题或内容"
|
||||
value={keyword}
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
allowClear
|
||||
className="mb-3"
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-auto">
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-center text-neutral-400 py-8">未找到可用知识条目</div>
|
||||
) : entries.map(entry => (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className="w-full text-left border border-neutral-100 rounded-lg p-3 hover:border-blue-300 hover:bg-blue-50"
|
||||
onClick={() => { onPick(entry.content); onClose() }}
|
||||
>
|
||||
<div className="text-sm font-medium text-neutral-800">{entry.title}</div>
|
||||
<div className="text-xs text-neutral-500 mt-1 line-clamp-2">{entry.content}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/** 快捷回复选择弹窗:搜索与列表状态内聚;应用后由父层关闭(applyQuickReply 内) */
|
||||
export function QuickReplyPickerModal({
|
||||
open,
|
||||
onClose,
|
||||
onApply,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onApply: (item: QuickReply) => void
|
||||
}) {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [list, setList] = useState<QuickReply[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
getQuickReplies({
|
||||
scope: 'all',
|
||||
q: keyword.trim() || undefined,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}).then(response => {
|
||||
setList(Array.isArray(response.list) ? response.list : [])
|
||||
}).catch(() => setList([])).finally(() => setLoading(false))
|
||||
}, [open, keyword])
|
||||
|
||||
return (
|
||||
<Modal title="快捷回复" open={open} onCancel={onClose} footer={null} width={640}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索标题、内容或输入码"
|
||||
value={keyword}
|
||||
onChange={event => setKeyword(event.target.value)}
|
||||
allowClear
|
||||
className="mb-3"
|
||||
/>
|
||||
<p className="text-xs text-neutral-400 mb-2">含团队已发布 + 我的话术。管理请到侧栏「快捷回复」。</p>
|
||||
{loading ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-auto">
|
||||
{list.length === 0 ? (
|
||||
<div className="text-center text-neutral-400 py-8">暂无快捷回复</div>
|
||||
) : list.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="w-full text-left border border-neutral-100 rounded-lg p-3 hover:border-blue-300 hover:bg-blue-50"
|
||||
onClick={() => onApply(item)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-800">{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded">/{item.shortcut}</code>
|
||||
)}
|
||||
<span className="text-[10px] text-neutral-400 ml-auto">
|
||||
{item.scope === 'team' ? '团队' : '个人'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 mt-1 line-clamp-2">{item.content}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Button, Form, Input, Select, Spin } from 'antd'
|
||||
import { TeamOutlined } from '@ant-design/icons'
|
||||
import type { FormInstance } from 'antd'
|
||||
import type { SaveSettingsFn } from './types'
|
||||
|
||||
interface AssignmentSectionProps {
|
||||
assignForm: FormInstance
|
||||
loading: boolean
|
||||
isAdmin: boolean
|
||||
saving: boolean
|
||||
onSave: SaveSettingsFn
|
||||
}
|
||||
|
||||
/** 客服分配规则:自动分配策略与并发上限 */
|
||||
const AssignmentSection = ({ assignForm, loading, isAdmin, saving, onSave }: AssignmentSectionProps) => {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-blue-50/50 rounded-2xl border border-blue-100 p-5">
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 text-blue-600 flex items-center justify-center shrink-0 text-lg">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-bold text-slate-900">自动分配规则</div>
|
||||
<div className="text-xs text-slate-600 mt-1 leading-relaxed">
|
||||
工作时间内,访客进线会按下列策略自动分配给<strong className="font-semibold text-blue-700">在线一线客服</strong>;
|
||||
非工作时间或无可接坐席时进入排队或离线留言。人工领取与内部转接不受此策略限制。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8">
|
||||
<Form
|
||||
form={assignForm}
|
||||
layout="vertical"
|
||||
disabled={!isAdmin}
|
||||
onFinish={async (values: {
|
||||
assign_strategy: 'least_load' | 'round_robin'
|
||||
max_active_per_agent: number
|
||||
}) => {
|
||||
await onSave({
|
||||
assign_strategy: values.assign_strategy,
|
||||
max_active_per_agent: Number(values.max_active_per_agent) || 0,
|
||||
}, '分配规则已保存')
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="assign_strategy"
|
||||
label={<span className="text-xs font-semibold text-slate-700">分配策略</span>}
|
||||
rules={[{ required: true, message: '请选择策略' }]}
|
||||
extra={<span className="text-[11px] text-slate-400">负载最低:优先分配给进行中会话最少的坐席;轮询:在可接坐席中均匀依次分发。</span>}
|
||||
>
|
||||
<Select
|
||||
className="!rounded-lg"
|
||||
options={[
|
||||
{ value: 'least_load', label: '负载最低策略(推荐)' },
|
||||
{ value: 'round_robin', label: '轮询轮转分配策略' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="max_active_per_agent"
|
||||
label={<span className="text-xs font-semibold text-slate-700">每位坐席最大并发会话数</span>}
|
||||
rules={[
|
||||
{ required: true, message: '请填写上限' },
|
||||
{
|
||||
validator: async (_, v) => {
|
||||
const n = Number(v)
|
||||
if (!Number.isFinite(n) || n < 0 || n > 200) {
|
||||
throw new Error('请填写 0–200 的整数,0 表示不限制')
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra={<span className="text-[11px] text-slate-400">达到上限后不再自动接入新访客,全部坐席满载时访客将进入等待队列。0 表示不限上限。</span>}
|
||||
>
|
||||
<Input type="number" min={0} max={200} placeholder="0 表示不限制" className="!rounded-lg" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="pt-4 border-t border-slate-100 flex items-center justify-between">
|
||||
{isAdmin ? (
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
className="!h-9 !px-6 !rounded-xl !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
>
|
||||
保存分配规则
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 m-0">仅租户管理员可修改分配规则</p>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50/80 rounded-2xl border border-slate-200/80 p-4 text-xs text-slate-500 leading-relaxed space-y-1">
|
||||
<div className="font-bold text-slate-700 mb-1">使用说明</div>
|
||||
<p className="m-0">· 自动分发候选范围:角色为客服、当前状态为「在线」的账号(忙碌/离线状态不参与自动分发)。</p>
|
||||
<p className="m-0">· 专属坐席组与渠道绑定策略将在后续版本逐步上线。</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AssignmentSection
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Button, Form, Spin, Upload, message } from 'antd'
|
||||
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PictureOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { uploadImage, type WelcomeSegment } from '@/services/api'
|
||||
import MarkdownEditor from '@/components/common/MarkdownEditor'
|
||||
import RichTextEditor from '@/components/common/RichTextEditor'
|
||||
import { MAX_WELCOME_SEGMENTS } from './welcome'
|
||||
import type { SaveSettingsFn } from './types'
|
||||
|
||||
interface AutoReplySectionProps {
|
||||
autoReplyForm: FormInstance
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
welcomeSegments: WelcomeSegment[]
|
||||
setWelcomeSegments: Dispatch<SetStateAction<WelcomeSegment[]>>
|
||||
uploadingWelcomeIdx: number | null
|
||||
setUploadingWelcomeIdx: Dispatch<SetStateAction<number | null>>
|
||||
onSave: SaveSettingsFn
|
||||
}
|
||||
|
||||
/** 自动回复:多段欢迎语(含图片)与离线留言提示 */
|
||||
const AutoReplySection = ({
|
||||
autoReplyForm,
|
||||
loading,
|
||||
saving,
|
||||
welcomeSegments,
|
||||
setWelcomeSegments,
|
||||
uploadingWelcomeIdx,
|
||||
setUploadingWelcomeIdx,
|
||||
onSave,
|
||||
}: AutoReplySectionProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Form
|
||||
form={autoReplyForm}
|
||||
layout="vertical"
|
||||
onFinish={async values => {
|
||||
if (welcomeSegments.length === 0) {
|
||||
message.error('至少保留一段欢迎语')
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < welcomeSegments.length; i++) {
|
||||
const seg = welcomeSegments[i]
|
||||
if (seg.type === 'text') {
|
||||
const plain = seg.content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim()
|
||||
if (!plain) {
|
||||
message.error(`第 ${i + 1} 段文本不能为空`)
|
||||
return
|
||||
}
|
||||
}
|
||||
if (seg.type === 'image' && !seg.content.trim()) {
|
||||
message.error(`第 ${i + 1} 段请上传图片`)
|
||||
return
|
||||
}
|
||||
}
|
||||
await onSave({
|
||||
welcome_messages: welcomeSegments.map(s => ({
|
||||
type: s.type,
|
||||
content: s.content.trim(),
|
||||
})),
|
||||
offline_prompt: values.offline_prompt,
|
||||
}, '自动回复已保存')
|
||||
}}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-3 pb-3 border-b border-slate-100">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-900 m-0 mb-1">欢迎语(多段混合展示)</h3>
|
||||
<p className="text-xs text-slate-400 m-0">
|
||||
访客首次进入聊天窗口时按序自动推送;支持图文混排,最多可添加 {MAX_WELCOME_SEGMENTS} 段。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{welcomeSegments.map((seg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-xl border border-slate-200/80 bg-slate-50/60 p-4 transition-all hover:bg-slate-50"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2.5">
|
||||
<span className="text-xs font-semibold text-slate-700">
|
||||
第 {idx + 1} 段 · {seg.type === 'image' ? '图片消息' : '富文本消息'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={idx === 0}
|
||||
className="!rounded-lg"
|
||||
onClick={() => {
|
||||
setWelcomeSegments(prev => {
|
||||
if (idx <= 0) return prev
|
||||
const next = [...prev]
|
||||
;[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={idx >= welcomeSegments.length - 1}
|
||||
className="!rounded-lg"
|
||||
onClick={() => {
|
||||
setWelcomeSegments(prev => {
|
||||
if (idx >= prev.length - 1) return prev
|
||||
const next = [...prev]
|
||||
;[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={welcomeSegments.length <= 1}
|
||||
className="!rounded-lg"
|
||||
onClick={() => {
|
||||
setWelcomeSegments(prev => prev.filter((_, i) => i !== idx))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{seg.type === 'text' ? (
|
||||
<RichTextEditor
|
||||
value={seg.content}
|
||||
placeholder="输入欢迎语,选中文字后设置颜色、字号…"
|
||||
onChange={v => {
|
||||
setWelcomeSegments(prev =>
|
||||
prev.map((s, i) => (i === idx ? { ...s, content: v } : s)),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
{seg.content ? (
|
||||
<img
|
||||
src={seg.content}
|
||||
alt={`欢迎图 ${idx + 1}`}
|
||||
className="max-h-28 max-w-[200px] rounded-xl border border-slate-200 object-contain bg-white shadow-2xs"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-28 h-20 rounded-xl border border-dashed border-slate-300 flex items-center justify-center text-slate-400 text-xs bg-white">
|
||||
未上传
|
||||
</div>
|
||||
)}
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
setUploadingWelcomeIdx(idx)
|
||||
try {
|
||||
const res = await uploadImage(file as File)
|
||||
const url = res.data?.url
|
||||
if (!url) throw new Error('上传未返回地址')
|
||||
setWelcomeSegments(prev =>
|
||||
prev.map((s, i) => (i === idx ? { ...s, content: url } : s)),
|
||||
)
|
||||
onSuccess?.(res)
|
||||
message.success('图片已上传')
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('上传失败')
|
||||
onError?.(err)
|
||||
message.error(err.message)
|
||||
} finally {
|
||||
setUploadingWelcomeIdx(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<PictureOutlined />}
|
||||
loading={uploadingWelcomeIdx === idx}
|
||||
size="small"
|
||||
className="!rounded-lg !text-xs !h-8"
|
||||
>
|
||||
{seg.content ? '重新上传' : '上传图片'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2.5 mt-3.5">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
className="!rounded-lg !text-xs !h-8"
|
||||
disabled={welcomeSegments.length >= MAX_WELCOME_SEGMENTS}
|
||||
onClick={() =>
|
||||
setWelcomeSegments(prev =>
|
||||
prev.length >= MAX_WELCOME_SEGMENTS
|
||||
? prev
|
||||
: [...prev, { type: 'text', content: '' }],
|
||||
)
|
||||
}
|
||||
>
|
||||
添加文本段
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PictureOutlined />}
|
||||
className="!rounded-lg !text-xs !h-8"
|
||||
disabled={welcomeSegments.length >= MAX_WELCOME_SEGMENTS}
|
||||
onClick={() =>
|
||||
setWelcomeSegments(prev =>
|
||||
prev.length >= MAX_WELCOME_SEGMENTS
|
||||
? prev
|
||||
: [...prev, { type: 'image', content: '' }],
|
||||
)
|
||||
}
|
||||
>
|
||||
添加图片段
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-slate-100 mb-6">
|
||||
<Form.Item
|
||||
name="offline_prompt"
|
||||
label={<span className="text-xs font-semibold text-slate-700">离线留言提示语</span>}
|
||||
rules={[{ max: 500 }]}
|
||||
extra={<span className="text-[11px] text-slate-400">当座席全部离线或在非工作时段进线时向访客展示</span>}
|
||||
>
|
||||
<MarkdownEditor
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="当前无坐席在线,请留下您的联系方式与问题…"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-100 flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">更新自动回复后实时对新访客生效</span>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
className="!h-9 !px-6 !rounded-xl !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
>
|
||||
保存自动回复
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AutoReplySection
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Button, Form, Input, Select, Spin } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import type { SaveSettingsFn } from './types'
|
||||
|
||||
interface BasicSectionProps {
|
||||
basicForm: FormInstance
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
onSave: SaveSettingsFn
|
||||
}
|
||||
|
||||
/** 基本设置:租户展示档案与区域本地化 */
|
||||
const BasicSection = ({ basicForm, loading, saving, onSave }: BasicSectionProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Form
|
||||
form={basicForm}
|
||||
layout="vertical"
|
||||
onFinish={values => onSave({
|
||||
display_name: values.display_name,
|
||||
agent_nickname: values.agent_nickname,
|
||||
timezone: values.timezone,
|
||||
})}
|
||||
>
|
||||
<div className="mb-6 pb-5 border-b border-slate-100">
|
||||
<h3 className="text-sm font-bold text-slate-900 m-0 mb-1">企业展示档案</h3>
|
||||
<p className="text-xs text-slate-400 m-0">配置访客端看到的租户品牌名称与在线客服默认身份</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 mb-2">
|
||||
<Form.Item
|
||||
name="display_name"
|
||||
label={<span className="text-xs font-semibold text-slate-700">租户名称</span>}
|
||||
rules={[{ required: true, message: '请输入租户名称' }, { min: 1, max: 100 }]}
|
||||
extra={<span className="text-[11px] text-slate-400">公司或品牌名称,展示于访客端聊天窗口顶部</span>}
|
||||
>
|
||||
<Input placeholder="公司名称 / 访客端展示名" className="!rounded-lg" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="agent_nickname"
|
||||
label={<span className="text-xs font-semibold text-slate-700">客服默认昵称</span>}
|
||||
extra={<span className="text-[11px] text-slate-400">未设置个人独立昵称的坐席将默认以此名称展示</span>}
|
||||
>
|
||||
<Input placeholder="客服在访客端显示的昵称" maxLength={50} className="!rounded-lg" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 mb-6 pb-5 border-b border-slate-100">
|
||||
<h3 className="text-sm font-bold text-slate-900 m-0 mb-1">区域与本地化</h3>
|
||||
<p className="text-xs text-slate-400 m-0">定义系统的标准服务时区与工作台界面语言</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 mb-8">
|
||||
<Form.Item
|
||||
name="timezone"
|
||||
label={<span className="text-xs font-semibold text-slate-700">服务时区</span>}
|
||||
extra={<span className="text-[11px] text-slate-400">影响营业时间判断、会话统计与历史报表生成</span>}
|
||||
>
|
||||
<Select
|
||||
className="!rounded-lg"
|
||||
options={[
|
||||
{ value: 'Asia/Shanghai', label: '东八区 (北京/上海 UTC+8)' },
|
||||
{ value: 'Asia/Tokyo', label: '东京 (UTC+9)' },
|
||||
{ value: 'UTC', label: 'UTC 标准时间' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="language"
|
||||
label={<span className="text-xs font-semibold text-slate-700">系统语言</span>}
|
||||
extra={<span className="text-[11px] text-slate-400">当前默认简体中文(多语言包支持中)</span>}
|
||||
>
|
||||
<Select options={[{ value: 'zh-CN', label: '简体中文 (Simplified Chinese)' }]} disabled />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="pt-5 border-t border-slate-100 flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">配置保存后将实时对访客端与坐席工作台生效</span>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
className="!h-9 !px-6 !rounded-xl !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
>
|
||||
保存基本设置
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BasicSection
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Button, Empty, Spin, Switch, message } from 'antd'
|
||||
import { CopyOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { Channel } from '@/services/api'
|
||||
import { typeMeta } from './channelMeta'
|
||||
|
||||
/** 复制接入脚本(自动替换为当前站点域名) */
|
||||
const copyScript = async (script: string) => {
|
||||
try {
|
||||
const origin = window.location.origin
|
||||
const text = script.includes('src="/widget.js"')
|
||||
? script.replace('src="/widget.js"', `src="${origin}/widget.js"`)
|
||||
: script
|
||||
await navigator.clipboard.writeText(text)
|
||||
message.success('已复制接入代码')
|
||||
} catch {
|
||||
message.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
interface ChannelsSectionProps {
|
||||
loadingChannels: boolean
|
||||
channels: Channel[]
|
||||
missingTypes: string[]
|
||||
togglingId: number | null
|
||||
toggleChannel: (ch: Channel, enabled: boolean) => void
|
||||
ensureWebChannel: () => void
|
||||
addChannelType: (t: string) => void
|
||||
}
|
||||
|
||||
/** 渠道管理:接入渠道列表与嵌入代码 */
|
||||
const ChannelsSection = ({
|
||||
loadingChannels,
|
||||
channels,
|
||||
missingTypes,
|
||||
togglingId,
|
||||
toggleChannel,
|
||||
ensureWebChannel,
|
||||
addChannelType,
|
||||
}: ChannelsSectionProps) => {
|
||||
return (
|
||||
<div>
|
||||
{loadingChannels ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : channels.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 p-12 text-center shadow-xs">
|
||||
<Empty description="暂无渠道,请先开通网页渠道">
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={ensureWebChannel}
|
||||
className="!h-9 !px-5 !rounded-xl !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
>
|
||||
开通网页渠道
|
||||
</Button>
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{channels.map(ch => {
|
||||
const meta = typeMeta[ch.type] || { icon: <LinkOutlined />, label: ch.name || ch.type, desc: '' }
|
||||
const enabled = ch.status === 'enabled'
|
||||
return (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-5 transition-all hover:border-slate-300"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center text-xl shrink-0 border border-blue-100">
|
||||
{meta.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<h3 className="text-sm font-bold text-slate-900 m-0 truncate">
|
||||
{ch.name || meta.label}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${
|
||||
enabled ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' : 'bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{enabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
size="small"
|
||||
loading={togglingId === ch.id}
|
||||
onChange={v => toggleChannel(ch, v)}
|
||||
/>
|
||||
</div>
|
||||
{meta.desc && (
|
||||
<p className="text-xs text-slate-400 m-0 mb-3">{meta.desc}</p>
|
||||
)}
|
||||
{ch.channel_key && (
|
||||
<div className="flex items-center gap-2 text-xs mb-2">
|
||||
<span className="text-slate-400 font-medium">渠道 ID</span>
|
||||
<code className="bg-slate-100 text-slate-700 px-2 py-0.5 rounded-md font-mono text-[11px]">
|
||||
{ch.channel_key}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{ch.type === 'web' && ch.script_code && (
|
||||
<div className="mt-3">
|
||||
<div className="text-xs font-semibold text-slate-600 mb-1.5">网站嵌入代码</div>
|
||||
<div className="bg-slate-50 border border-slate-200/80 rounded-xl p-3 flex items-start justify-between gap-3">
|
||||
<code className="text-[11px] text-slate-600 break-all leading-relaxed font-mono">
|
||||
{ch.script_code.replace(
|
||||
'src="/widget.js"',
|
||||
`src="${window.location.origin}/widget.js"`,
|
||||
)}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 hover:text-blue-600 hover:bg-blue-50 border border-slate-200 bg-white cursor-pointer transition-colors shadow-2xs"
|
||||
title="复制代码"
|
||||
onClick={() => copyScript(ch.script_code)}
|
||||
>
|
||||
<CopyOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ch.type !== 'web' && (
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
本期仅网页渠道可完整接入,其他类型预留开关。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingTypes.length > 0 && channels.length > 0 && (
|
||||
<div className="mt-6 bg-white rounded-2xl border border-slate-200/80 p-5 shadow-xs">
|
||||
<div className="text-xs font-bold text-slate-700 mb-3">可添加其他渠道</div>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{missingTypes.map(t => (
|
||||
<Button key={t} size="small" icon={<PlusOutlined />} className="!rounded-lg !text-xs !h-8" onClick={() => addChannelType(t)}>
|
||||
{typeMeta[t].label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelsSection
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Button, Form, Spin, Switch } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import type { SaveSettingsFn } from './types'
|
||||
|
||||
interface NotifySectionProps {
|
||||
notifyForm: FormInstance
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
onSave: SaveSettingsFn
|
||||
}
|
||||
|
||||
/** 通知设置:新会话、留言与日报偏好 */
|
||||
const NotifySection = ({ notifyForm, loading, saving, onSave }: NotifySectionProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Form
|
||||
form={notifyForm}
|
||||
layout="vertical"
|
||||
onFinish={values => onSave({
|
||||
notify_new_session: values.notify_new_session,
|
||||
notify_offline_leave: values.notify_offline_leave,
|
||||
notify_daily_report: values.notify_daily_report,
|
||||
}, '通知设置已保存')}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-900 m-0 mb-1">通知推送偏好</h3>
|
||||
<p className="text-xs text-slate-400 m-0">配置各类关键业务事件的系统通知与推送规则</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{[
|
||||
{ name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒;浏览器通知与声音可在工作台铃铛中配置' },
|
||||
{ name: 'notify_offline_leave', title: '离线留言通知', desc: '访客在离线时段提交留言时发送系统提醒' },
|
||||
{ name: 'notify_daily_report', title: '服务日报推送', desc: '每日定时汇总推送坐席接待与满意度数据摘要' },
|
||||
].map(item => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex items-center justify-between p-4 border border-slate-200/80 rounded-xl bg-slate-50/50 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 pr-4">
|
||||
<div className="text-sm font-bold text-slate-800">{item.title}</div>
|
||||
<div className="text-xs text-slate-400 mt-0.5">{item.desc}</div>
|
||||
</div>
|
||||
<Form.Item name={item.name} valuePropName="checked" className="!mb-0">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-100 flex items-center justify-end">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
className="!h-9 !px-6 !rounded-xl !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
>
|
||||
保存通知设置
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotifySection
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Form, Input, Modal, Select } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import type { StaffUser } from '@/services/api'
|
||||
|
||||
interface StaffModalProps {
|
||||
staffModalOpen: boolean
|
||||
setStaffModalOpen: Dispatch<SetStateAction<boolean>>
|
||||
editingStaff: StaffUser | null
|
||||
savingStaff: boolean
|
||||
isAdmin: boolean
|
||||
staffForm: FormInstance
|
||||
onFinish: (values: {
|
||||
username?: string; password?: string; nickname?: string; role?: string; status?: string
|
||||
}) => void
|
||||
}
|
||||
|
||||
/** 新建 / 编辑坐席弹窗 */
|
||||
const StaffModal = ({
|
||||
staffModalOpen,
|
||||
setStaffModalOpen,
|
||||
editingStaff,
|
||||
savingStaff,
|
||||
isAdmin,
|
||||
staffForm,
|
||||
onFinish,
|
||||
}: StaffModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
title={editingStaff ? '编辑坐席' : '添加坐席'}
|
||||
open={staffModalOpen}
|
||||
onCancel={() => setStaffModalOpen(false)}
|
||||
onOk={() => staffForm.submit()}
|
||||
confirmLoading={savingStaff}
|
||||
destroyOnClose
|
||||
okText="保存"
|
||||
width={440}
|
||||
>
|
||||
<Form form={staffForm} layout="vertical" onFinish={onFinish} className="mt-2" requiredMark={false}>
|
||||
{!editingStaff && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label="登录用户名"
|
||||
rules={[{ required: true, message: '请输入用户名' }, { min: 3, max: 30, message: '3-30 个字符' }]}
|
||||
>
|
||||
<Input placeholder="英文/数字,全局唯一" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="初始密码"
|
||||
rules={[{ required: true, message: '请输入密码' }, { min: 6, max: 64, message: '至少 6 位' }]}
|
||||
>
|
||||
<Input.Password placeholder="至少 6 位" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item name="nickname" label="显示昵称" rules={[{ max: 50 }]}>
|
||||
<Input placeholder="工作台显示名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={
|
||||
editingStaff?.role === 'admin'
|
||||
? [
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'supervisor', label: '主管' },
|
||||
{ value: 'agent', label: '客服' },
|
||||
]
|
||||
: [
|
||||
{ value: 'agent', label: '客服' },
|
||||
{ value: 'supervisor', label: '主管' },
|
||||
...(isAdmin ? [{ value: 'admin', label: '管理员' }] : []),
|
||||
]
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{editingStaff && (
|
||||
<>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'online', label: '在线' },
|
||||
{ value: 'offline', label: '离线' },
|
||||
{ value: 'busy', label: '忙碌' },
|
||||
{ value: 'disabled', label: '禁用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="重置密码" extra="留空则不修改">
|
||||
<Input.Password placeholder="可选,至少 6 位" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default StaffModal
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Empty, Popconfirm, Spin } from 'antd'
|
||||
import { CheckCircleOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'
|
||||
import type { StaffUser } from '@/services/api'
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
supervisor: '主管',
|
||||
agent: '客服',
|
||||
}
|
||||
|
||||
const statusLabel: Record<string, { text: string; className: string }> = {
|
||||
online: { text: '在线', className: 'bg-emerald-50 text-emerald-700' },
|
||||
offline: { text: '离线', className: 'bg-neutral-100 text-neutral-600' },
|
||||
busy: { text: '忙碌', className: 'bg-amber-50 text-amber-700' },
|
||||
disabled: { text: '已禁用', className: 'bg-red-50 text-red-600' },
|
||||
}
|
||||
|
||||
interface StaffSectionProps {
|
||||
canViewStaff: boolean
|
||||
loadingStaff: boolean
|
||||
staffList: StaffUser[]
|
||||
isAdmin: boolean
|
||||
seatUsed: number
|
||||
seatLimit: number
|
||||
currentUserId?: number
|
||||
openEditStaff: (s: StaffUser) => void
|
||||
handleEnableStaff: (s: StaffUser) => void
|
||||
handleDisableStaff: (s: StaffUser) => void
|
||||
}
|
||||
|
||||
/** 坐席账号:配额占用与坐席列表 */
|
||||
const StaffSection = ({
|
||||
canViewStaff,
|
||||
loadingStaff,
|
||||
staffList,
|
||||
isAdmin,
|
||||
seatUsed,
|
||||
seatLimit,
|
||||
currentUserId,
|
||||
openEditStaff,
|
||||
handleEnableStaff,
|
||||
handleDisableStaff,
|
||||
}: StaffSectionProps) => {
|
||||
return (
|
||||
<div>
|
||||
{!canViewStaff ? (
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 p-8 text-center text-sm text-slate-500 shadow-xs">
|
||||
仅管理员或主管可查看坐席列表
|
||||
</div>
|
||||
) : loadingStaff ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-4 mb-5 flex-wrap">
|
||||
<div className="rounded-2xl bg-white border border-slate-200/80 px-5 py-3.5 min-w-[160px] shadow-xs">
|
||||
<div className="text-[11px] font-medium text-slate-400 mb-0.5">坐席配额占用</div>
|
||||
<div className="text-xl font-bold text-slate-900 tabular-nums">
|
||||
{seatUsed}
|
||||
<span className="text-sm font-normal text-slate-400"> / {seatLimit}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 flex-1 min-w-[200px] leading-relaxed">
|
||||
启用中的管理员、主管、客服各占 1 个坐席。禁用账号可即刻释放坐席配额。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{staffList.length === 0 ? (
|
||||
<Empty description="暂无坐席账号" className="bg-white rounded-2xl border border-slate-200/80 py-12 shadow-xs" />
|
||||
) : (
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white overflow-hidden shadow-xs">
|
||||
<table className="w-full border-collapse min-w-[640px]">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/80 text-[11px] font-bold text-slate-500">
|
||||
<th className="text-left px-5 py-3 border-b border-slate-100">账号</th>
|
||||
<th className="text-left px-5 py-3 border-b border-slate-100">昵称</th>
|
||||
<th className="text-left px-5 py-3 border-b border-slate-100">角色</th>
|
||||
<th className="text-left px-5 py-3 border-b border-slate-100">状态</th>
|
||||
<th className="text-left px-5 py-3 border-b border-slate-100">创建时间</th>
|
||||
{isAdmin && (
|
||||
<th className="text-right px-5 py-3 border-b border-slate-100">操作</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{staffList.map(s => {
|
||||
const st = statusLabel[s.status] || statusLabel.offline
|
||||
return (
|
||||
<tr key={s.id} className="hover:bg-slate-50/60 border-t border-slate-100 transition-colors">
|
||||
<td className="px-5 py-3.5 text-sm font-semibold text-slate-800">{s.username}</td>
|
||||
<td className="px-5 py-3.5 text-sm text-slate-600">{s.nickname || '—'}</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="text-xs px-2.5 py-0.5 rounded-full bg-slate-100 text-slate-700 font-medium">
|
||||
{roleLabel[s.role] || s.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5">
|
||||
<span className={`text-xs px-2.5 py-0.5 rounded-full font-medium ${st.className}`}>
|
||||
{st.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-xs text-slate-400 whitespace-nowrap">
|
||||
{s.created_at ? s.created_at.slice(0, 16) : '—'}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-3.5 text-right" onClick={e => e.stopPropagation()}>
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
title="编辑"
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-blue-600 hover:bg-blue-50 border border-slate-200/80 bg-white cursor-pointer transition-colors shadow-2xs"
|
||||
onClick={() => openEditStaff(s)}
|
||||
>
|
||||
<EditOutlined className="text-xs" />
|
||||
</button>
|
||||
{s.status === 'disabled' ? (
|
||||
<button
|
||||
type="button"
|
||||
title="启用"
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-emerald-600 hover:bg-emerald-50 border border-slate-200/80 bg-white cursor-pointer transition-colors shadow-2xs"
|
||||
onClick={() => handleEnableStaff(s)}
|
||||
>
|
||||
<CheckCircleOutlined className="text-xs" />
|
||||
</button>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title="确认禁用该账号?"
|
||||
description="禁用后无法登录,并释放坐席"
|
||||
onConfirm={() => handleDisableStaff(s)}
|
||||
disabled={s.id === currentUserId}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="禁用"
|
||||
disabled={s.id === currentUserId}
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-red-500 hover:bg-red-50 border border-slate-200/80 bg-white cursor-pointer disabled:opacity-30 transition-colors shadow-2xs"
|
||||
>
|
||||
<StopOutlined className="text-xs" />
|
||||
</button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StaffSection
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Form, Input, Modal, Select, message } from 'antd'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { createCustomerTag, updateCustomerTag, type CustomerTag } from '@/services/api'
|
||||
import { tagColorPresets } from './tagColors'
|
||||
|
||||
interface TagModalProps {
|
||||
tagModalOpen: boolean
|
||||
setTagModalOpen: Dispatch<SetStateAction<boolean>>
|
||||
editingTag: CustomerTag | null
|
||||
savingTag: boolean
|
||||
setSavingTag: Dispatch<SetStateAction<boolean>>
|
||||
tagForm: FormInstance
|
||||
loadCustomerTags: () => Promise<void>
|
||||
}
|
||||
|
||||
/** 新建 / 编辑客户标签弹窗 */
|
||||
const TagModal = ({
|
||||
tagModalOpen,
|
||||
setTagModalOpen,
|
||||
editingTag,
|
||||
savingTag,
|
||||
setSavingTag,
|
||||
tagForm,
|
||||
loadCustomerTags,
|
||||
}: TagModalProps) => {
|
||||
return (
|
||||
<Modal
|
||||
title={editingTag ? '编辑客户标签' : '新建客户标签'}
|
||||
open={tagModalOpen}
|
||||
onCancel={() => setTagModalOpen(false)}
|
||||
onOk={() => tagForm.submit()}
|
||||
confirmLoading={savingTag}
|
||||
destroyOnHidden
|
||||
okText="保存"
|
||||
width={400}
|
||||
>
|
||||
<Form
|
||||
form={tagForm}
|
||||
layout="vertical"
|
||||
className="mt-2"
|
||||
onFinish={async values => {
|
||||
setSavingTag(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: String(values.name || '').trim(),
|
||||
color: values.color || 'slate',
|
||||
}
|
||||
if (editingTag) {
|
||||
await updateCustomerTag(editingTag.id, payload)
|
||||
message.success('标签已更新')
|
||||
} else {
|
||||
await createCustomerTag(payload)
|
||||
message.success('标签已创建')
|
||||
}
|
||||
setTagModalOpen(false)
|
||||
await loadCustomerTags()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSavingTag(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="标签名称"
|
||||
rules={[{ required: true, message: '请输入名称' }, { max: 30, message: '最多 30 字' }]}
|
||||
>
|
||||
<Input maxLength={30} placeholder="如:VIP客户、高意向" />
|
||||
</Form.Item>
|
||||
<Form.Item name="color" label="颜色" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={tagColorPresets.map(p => ({
|
||||
value: p.value,
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: p.color }} />
|
||||
{p.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagModal
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { Button, Empty, Popconfirm, Spin, message } from 'antd'
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import type { FormInstance } from 'antd'
|
||||
import { deleteCustomerTag, type CustomerTag } from '@/services/api'
|
||||
import { tagColorStyle } from './tagColors'
|
||||
|
||||
interface TagsSectionProps {
|
||||
customerTags: CustomerTag[]
|
||||
loadingTags: boolean
|
||||
tagForm: FormInstance
|
||||
setEditingTag: Dispatch<SetStateAction<CustomerTag | null>>
|
||||
setTagModalOpen: Dispatch<SetStateAction<boolean>>
|
||||
loadCustomerTags: () => Promise<void>
|
||||
}
|
||||
|
||||
/** 客户标签库 */
|
||||
const TagsSection = ({
|
||||
customerTags,
|
||||
loadingTags,
|
||||
tagForm,
|
||||
setEditingTag,
|
||||
setTagModalOpen,
|
||||
loadCustomerTags,
|
||||
}: TagsSectionProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8">
|
||||
<div className="flex items-start justify-between gap-3 mb-5 pb-4 border-b border-slate-100">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-900 m-0 mb-1">客户标签库</h3>
|
||||
<p className="text-xs text-slate-400 m-0">
|
||||
维护企业标准标签体系,坐席可在客户管理与工作台快捷打标。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
className="!h-8 !rounded-lg !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
onClick={() => {
|
||||
setEditingTag(null)
|
||||
tagForm.resetFields()
|
||||
tagForm.setFieldsValue({ color: 'blue' })
|
||||
setTagModalOpen(true)
|
||||
}}
|
||||
>
|
||||
新建标签
|
||||
</Button>
|
||||
</div>
|
||||
{loadingTags ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : customerTags.length === 0 ? (
|
||||
<Empty description="暂无标签,请先创建" className="py-12" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{customerTags.map(tag => {
|
||||
const style = tagColorStyle(tag.color)
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3 rounded-xl border border-slate-200/80 bg-slate-50/50 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="inline-flex items-center px-3 py-1 rounded-lg text-xs font-semibold"
|
||||
style={{ backgroundColor: style.bg, color: style.color }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
title="编辑"
|
||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-slate-400 hover:text-blue-600 hover:bg-blue-50 border border-slate-200/80 bg-white cursor-pointer transition-colors shadow-2xs"
|
||||
onClick={() => {
|
||||
setEditingTag(tag)
|
||||
tagForm.setFieldsValue({ name: tag.name, color: tag.color || 'slate' })
|
||||
setTagModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<EditOutlined className="text-xs" />
|
||||
</button>
|
||||
<Popconfirm
|
||||
title="删除该标签?"
|
||||
description="将从标签库移除,并同步去掉客户上的此标签"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteCustomerTag(tag.id)
|
||||
message.success('已删除')
|
||||
await loadCustomerTags()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagsSection
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Button, Form, Input, Select, Spin, TimePicker, message } from 'antd'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import type { FormInstance } from 'antd'
|
||||
import type { WorkHours } from '@/services/api'
|
||||
import {
|
||||
defaultCustomRange, encodeWorkSlot, weekDays, workModeOptions, workRangePresets, type WorkMode,
|
||||
} from './worktime'
|
||||
import type { SaveSettingsFn } from './types'
|
||||
|
||||
interface WorktimeSectionProps {
|
||||
workForm: FormInstance
|
||||
loading: boolean
|
||||
saving: boolean
|
||||
onSave: SaveSettingsFn
|
||||
}
|
||||
|
||||
/** 工作时间:每周服务时段与非工作时间提示 */
|
||||
const WorktimeSection = ({ workForm, loading, saving, onSave }: WorktimeSectionProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-6 md:p-8">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Form
|
||||
form={workForm}
|
||||
layout="vertical"
|
||||
onFinish={values => {
|
||||
const work_hours: WorkHours = {}
|
||||
for (const d of weekDays) {
|
||||
const mode = values[`${d.key}_mode`] as WorkMode | undefined
|
||||
const range = values[`${d.key}_range`] as [Dayjs, Dayjs] | null | undefined
|
||||
if (mode === 'custom' && (!range?.[0] || !range?.[1])) {
|
||||
message.error(`${d.label} 请选择自定义时间范围`)
|
||||
return
|
||||
}
|
||||
work_hours[d.key] = encodeWorkSlot(mode, range)
|
||||
}
|
||||
return onSave({
|
||||
work_hours,
|
||||
worktime_prompt: values.worktime_prompt,
|
||||
}, '工作时间已保存')
|
||||
}}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-bold text-slate-900 m-0 mb-1">营业与服务时间段</h3>
|
||||
<p className="text-xs text-slate-400 m-0">
|
||||
设定每周工作日在线服务时间;非服务时间系统将向访客展示非工作时间提示。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-5">
|
||||
<Button
|
||||
size="small"
|
||||
className="!rounded-lg !text-xs !h-7.5"
|
||||
onClick={() => {
|
||||
const patch: Record<string, unknown> = {}
|
||||
weekDays.forEach((d, i) => {
|
||||
if (i < 5) {
|
||||
patch[`${d.key}_mode`] = 'custom'
|
||||
patch[`${d.key}_range`] = defaultCustomRange()
|
||||
} else {
|
||||
patch[`${d.key}_mode`] = 'rest'
|
||||
patch[`${d.key}_range`] = undefined
|
||||
}
|
||||
})
|
||||
workForm.setFieldsValue(patch)
|
||||
}}
|
||||
>
|
||||
工作日 09:00-18:00
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className="!rounded-lg !text-xs !h-7.5"
|
||||
onClick={() => {
|
||||
const patch: Record<string, unknown> = {}
|
||||
weekDays.forEach(d => {
|
||||
patch[`${d.key}_mode`] = 'allday'
|
||||
patch[`${d.key}_range`] = undefined
|
||||
})
|
||||
workForm.setFieldsValue(patch)
|
||||
}}
|
||||
>
|
||||
全部全天 24h
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
className="!rounded-lg !text-xs !h-7.5"
|
||||
onClick={() => {
|
||||
const patch: Record<string, unknown> = {}
|
||||
weekDays.forEach(d => {
|
||||
patch[`${d.key}_mode`] = 'rest'
|
||||
patch[`${d.key}_range`] = undefined
|
||||
})
|
||||
workForm.setFieldsValue(patch)
|
||||
}}
|
||||
>
|
||||
全部休息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200/80 overflow-hidden mb-6 bg-white">
|
||||
{weekDays.map((day, i) => (
|
||||
<div
|
||||
key={day.key}
|
||||
className={`flex flex-wrap items-center gap-x-4 gap-y-2 px-5 py-3 ${
|
||||
i < weekDays.length - 1 ? 'border-b border-slate-100' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold text-slate-700 w-12 shrink-0">{day.label}</span>
|
||||
<Form.Item name={`${day.key}_mode`} className="!mb-0" initialValue="rest">
|
||||
<Select
|
||||
options={workModeOptions}
|
||||
className="!w-[130px]"
|
||||
size="small"
|
||||
onChange={(mode: WorkMode) => {
|
||||
if (mode === 'custom' && !workForm.getFieldValue(`${day.key}_range`)) {
|
||||
workForm.setFieldValue(`${day.key}_range`, defaultCustomRange())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, cur) =>
|
||||
prev[`${day.key}_mode`] !== cur[`${day.key}_mode`]
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
workForm.getFieldValue(`${day.key}_mode`) === 'custom' ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Form.Item
|
||||
name={`${day.key}_range`}
|
||||
className="!mb-0"
|
||||
rules={[{ required: true, message: '请选择时间范围' }]}
|
||||
>
|
||||
<TimePicker.RangePicker
|
||||
format="HH:mm"
|
||||
size="small"
|
||||
allowClear={false}
|
||||
order={false}
|
||||
needConfirm={false}
|
||||
placeholder={['开始', '结束']}
|
||||
className="!w-[180px] !rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{workRangePresets.map(p => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="link"
|
||||
size="small"
|
||||
className="!px-1.5 !h-6 text-xs text-slate-400 hover:text-blue-600"
|
||||
onClick={() => {
|
||||
workForm.setFieldsValue({
|
||||
[`${day.key}_mode`]: 'custom',
|
||||
[`${day.key}_range`]: [
|
||||
dayjs(p.start, 'HH:mm'),
|
||||
dayjs(p.end, 'HH:mm'),
|
||||
],
|
||||
})
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="worktime_prompt"
|
||||
label={<span className="text-xs font-semibold text-slate-700">非工作时间提示语</span>}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input placeholder="当前为非工作时间,我们会在工作时段尽快回复您" maxLength={500} className="!rounded-lg" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="pt-4 border-t border-slate-100 flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">时段以系统时区为准</span>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
className="!h-9 !px-6 !rounded-xl !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
>
|
||||
保存工作时间
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WorktimeSection
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
|
||||
export const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = {
|
||||
web: { icon: <GlobalOutlined />, label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' },
|
||||
wechat: { icon: <WechatOutlined />, label: '微信公众号', desc: '对接微信公众号客服消息(预留)' },
|
||||
app: { icon: <MobileOutlined />, label: 'APP 内嵌', desc: '移动端 App 内嵌客服(预留)' },
|
||||
phone: { icon: <PhoneOutlined />, label: '电话客服', desc: '电话进线与回拨(预留)' },
|
||||
email: { icon: <MailOutlined />, label: '邮件工单', desc: '邮件自动创建工单(预留)' },
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
SettingOutlined, ApiOutlined, UserSwitchOutlined,
|
||||
TeamOutlined, TagsOutlined, ClockCircleOutlined,
|
||||
MessageOutlined, BellOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
export interface TabItem {
|
||||
key: string
|
||||
label: string
|
||||
desc: string
|
||||
icon: ReactNode
|
||||
}
|
||||
|
||||
export interface TabGroup {
|
||||
groupName: string
|
||||
items: TabItem[]
|
||||
}
|
||||
|
||||
export const tabGroups: TabGroup[] = [
|
||||
{
|
||||
groupName: '企业与渠道',
|
||||
items: [
|
||||
{ key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: <SettingOutlined /> },
|
||||
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
||||
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: '规则与路由',
|
||||
items: [
|
||||
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
|
||||
{ key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: <TagsOutlined /> },
|
||||
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupName: '自动化与通知',
|
||||
items: [
|
||||
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
|
||||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const tabItems: TabItem[] = tabGroups.flatMap(g => g.items)
|
||||
@@ -0,0 +1,17 @@
|
||||
export const tagColorPresets: { value: string; label: string; bg: string; color: string }[] = [
|
||||
{ value: 'amber', label: '琥珀', bg: '#fef3c7', color: '#92400e' },
|
||||
{ value: 'green', label: '绿色', bg: '#f0fdf4', color: '#16a34a' },
|
||||
{ value: 'blue', label: '蓝色', bg: '#dbeafe', color: '#2563eb' },
|
||||
{ value: 'cyan', label: '青色', bg: '#ecfeff', color: '#0891b2' },
|
||||
{ value: 'violet', label: '紫色', bg: '#f3e8ff', color: '#7c3aed' },
|
||||
{ value: 'rose', label: '玫红', bg: '#fff1f2', color: '#e11d48' },
|
||||
{ value: 'orange', label: '橙色', bg: '#fffbeb', color: '#d97706' },
|
||||
{ value: 'slate', label: '灰蓝', bg: '#f1f5f9', color: '#475569' },
|
||||
]
|
||||
|
||||
export function tagColorStyle(color: string): { bg: string; color: string } {
|
||||
const preset = tagColorPresets.find(p => p.value === color)
|
||||
if (preset) return { bg: preset.bg, color: preset.color }
|
||||
if (color?.startsWith('#')) return { bg: `${color}22`, color }
|
||||
return { bg: '#f1f5f9', color: '#475569' }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { updateTenantSettings } from '@/services/api'
|
||||
|
||||
/** 局部保存租户设置(与 Settings 入口文件 savePartial 的签名一致) */
|
||||
export type SaveSettingsFn = (
|
||||
payload: Parameters<typeof updateTenantSettings>[0],
|
||||
okText?: string,
|
||||
) => Promise<void>
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TenantSettings, WelcomeSegment } from '@/services/api'
|
||||
|
||||
export const MAX_WELCOME_SEGMENTS = 10
|
||||
|
||||
export function normalizeWelcomeFromSettings(data: TenantSettings): WelcomeSegment[] {
|
||||
if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) {
|
||||
return data.welcome_messages.map(s => ({
|
||||
type: s.type === 'image' ? 'image' : 'text',
|
||||
content: s.content || '',
|
||||
}))
|
||||
}
|
||||
if (data.welcome_message?.trim()) {
|
||||
return [{ type: 'text', content: data.welcome_message.trim() }]
|
||||
}
|
||||
return [{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
export const weekDays: { key: string; label: string }[] = [
|
||||
{ key: 'monday', label: '周一' },
|
||||
{ key: 'tuesday', label: '周二' },
|
||||
{ key: 'wednesday', label: '周三' },
|
||||
{ key: 'thursday', label: '周四' },
|
||||
{ key: 'friday', label: '周五' },
|
||||
{ key: 'saturday', label: '周六' },
|
||||
{ key: 'sunday', label: '周日' },
|
||||
]
|
||||
|
||||
/** 工作日模式:休息 / 全天 / 自定义时段 */
|
||||
export type WorkMode = 'rest' | 'allday' | 'custom'
|
||||
|
||||
export const workModeOptions: { value: WorkMode; label: string }[] = [
|
||||
{ value: 'rest', label: '休息' },
|
||||
{ value: 'allday', label: '全天' },
|
||||
{ value: 'custom', label: '自定义时段' },
|
||||
]
|
||||
|
||||
/** 快捷时段预设(选中后填入自定义时间范围) */
|
||||
export const workRangePresets: { label: string; start: string; end: string }[] = [
|
||||
{ label: '09:00-18:00', start: '09:00', end: '18:00' },
|
||||
{ label: '08:00-17:00', start: '08:00', end: '17:00' },
|
||||
{ label: '09:00-12:00', start: '09:00', end: '12:00' },
|
||||
{ label: '13:00-18:00', start: '13:00', end: '18:00' },
|
||||
{ label: '10:00-22:00', start: '10:00', end: '22:00' },
|
||||
]
|
||||
|
||||
export function parseWorkSlot(slot?: string): { mode: WorkMode; range?: [Dayjs, Dayjs] } {
|
||||
const s = (slot || '').trim()
|
||||
if (!s || s === '休息') return { mode: 'rest' }
|
||||
if (s === '全天') return { mode: 'allday' }
|
||||
const m = s.match(/^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/)
|
||||
if (m) {
|
||||
const start = dayjs(m[1], 'HH:mm')
|
||||
const end = dayjs(m[2], 'HH:mm')
|
||||
if (start.isValid() && end.isValid()) {
|
||||
return { mode: 'custom', range: [start, end] }
|
||||
}
|
||||
}
|
||||
return { mode: 'rest' }
|
||||
}
|
||||
|
||||
export function encodeWorkSlot(mode: WorkMode | undefined, range?: [Dayjs, Dayjs] | null): string {
|
||||
if (mode === 'allday') return '全天'
|
||||
if (mode === 'custom' && range?.[0]?.isValid() && range?.[1]?.isValid()) {
|
||||
return `${range[0].format('HH:mm')}-${range[1].format('HH:mm')}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function defaultCustomRange(): [Dayjs, Dayjs] {
|
||||
return [dayjs('09:00', 'HH:mm'), dayjs('18:00', 'HH:mm')]
|
||||
}
|
||||
+87
-385
@@ -1,56 +1,17 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { MessageOutlined } from '@ant-design/icons'
|
||||
import { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import {
|
||||
CloseOutlined, MessageOutlined, SendOutlined,
|
||||
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import MarkdownBody from '@/components/common/MarkdownBody'
|
||||
import SafeHtml, { looksLikeHtml } from '@/components/common/SafeHtml'
|
||||
defaultWelcomeSegments, parseWelcomeSegments,
|
||||
type HostPageInfo, type LayoutMode, type Message, type WelcomeSegment,
|
||||
} from './visitor/types'
|
||||
import { ChatHeader } from './visitor/ChatHeader'
|
||||
import { MessageList } from './visitor/MessageList'
|
||||
import { ChatComposer } from './visitor/ChatComposer'
|
||||
import { RatingOverlay } from './visitor/RatingOverlay'
|
||||
|
||||
interface Message {
|
||||
id: number
|
||||
sender: 'visitor' | 'agent'
|
||||
content: string
|
||||
time: string
|
||||
type?: 'text' | 'image'
|
||||
/** 服务端单调序号,用于增量同步 */
|
||||
seq?: number
|
||||
}
|
||||
export type { HostPageInfo } from './visitor/types'
|
||||
|
||||
const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||||
|
||||
type WelcomeSegment = { type: 'text' | 'image'; content: string }
|
||||
|
||||
const defaultWelcomeSegments: WelcomeSegment[] = [
|
||||
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
|
||||
]
|
||||
|
||||
function parseWelcomeSegments(data: {
|
||||
welcome_messages?: unknown
|
||||
welcome_message?: string
|
||||
}): WelcomeSegment[] {
|
||||
if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) {
|
||||
const segs: WelcomeSegment[] = []
|
||||
for (const raw of data.welcome_messages) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const item = raw as { type?: string; content?: string }
|
||||
const content = typeof item.content === 'string' ? item.content.trim() : ''
|
||||
if (!content) continue
|
||||
if (item.type === 'image') segs.push({ type: 'image', content })
|
||||
else segs.push({ type: 'text', content })
|
||||
}
|
||||
if (segs.length > 0) return segs
|
||||
}
|
||||
if (typeof data.welcome_message === 'string' && data.welcome_message.trim()) {
|
||||
return [{ type: 'text', content: data.welcome_message.trim() }]
|
||||
}
|
||||
return defaultWelcomeSegments
|
||||
}
|
||||
|
||||
type LayoutMode = 'floating' | 'fill'
|
||||
|
||||
export type HostPageInfo = { url?: string; title?: string; referrer?: string }
|
||||
|
||||
interface VisitorChatProps {
|
||||
defaultOpen?: boolean
|
||||
@@ -917,154 +878,31 @@ const VisitorChat = ({
|
||||
|
||||
const panel = open && (
|
||||
<div className={shellClass}>
|
||||
<header className="shrink-0 px-5 py-4 bg-gradient-to-r from-blue-600 via-blue-600 to-indigo-600 text-white flex items-center gap-3 shadow-sm">
|
||||
<div className="w-10 h-10 rounded-2xl bg-white/20 backdrop-blur-md flex items-center justify-center shrink-0 border border-white/20 shadow-inner">
|
||||
<CustomerServiceOutlined className="text-lg text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="m-0 text-sm font-bold leading-tight text-white">{displayName || '在线客服'}</h1>
|
||||
<p className="m-0 text-[11px] leading-normal text-white/90 flex items-center gap-1.5 mt-1 font-medium">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full ring-2 ring-white/30"
|
||||
style={{ background: sessionEnded ? '#94a3b8' : agentsOnline ? '#22c55e' : '#f59e0b' }}
|
||||
/>
|
||||
{sessionEnded
|
||||
? '会话已结束'
|
||||
: agentsOnline
|
||||
? (agentName ? `${agentName} 为您服务` : '客服在线,随时解答')
|
||||
: '暂无在线客服 · 欢迎留言'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isFill && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMinimize}
|
||||
className="w-7 h-7 rounded-xl bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white transition-colors"
|
||||
aria-label="最小化"
|
||||
>
|
||||
<MinusOutlined className="text-xs" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="w-7 h-7 rounded-xl bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseOutlined className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<ChatHeader
|
||||
displayName={displayName}
|
||||
sessionEnded={sessionEnded}
|
||||
agentsOnline={agentsOnline}
|
||||
agentName={agentName}
|
||||
isFill={isFill}
|
||||
onMinimize={handleMinimize}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
||||
<section className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-slate-50/80 no-scrollbar">
|
||||
{showWelcome && (
|
||||
<div className="flex flex-col items-start gap-2 max-w-[90%]">
|
||||
{agentsOnline ? (
|
||||
welcomeSegments.map((seg, i) =>
|
||||
seg.type === 'image' ? (
|
||||
<div
|
||||
key={`w-${i}`}
|
||||
className="rounded-xl bg-white border border-neutral-200 p-1.5"
|
||||
>
|
||||
<ChatImage
|
||||
src={seg.content}
|
||||
alt="欢迎图"
|
||||
className="max-w-[220px] max-h-[220px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={`w-${i}`}
|
||||
className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-700 leading-normal"
|
||||
>
|
||||
{looksLikeHtml(seg.content) ? (
|
||||
<SafeHtml html={seg.content} />
|
||||
) : (
|
||||
<MarkdownBody>{seg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-600 leading-normal">
|
||||
{looksLikeHtml(offlinePrompt) ? (
|
||||
<SafeHtml html={offlinePrompt} />
|
||||
) : (
|
||||
<MarkdownBody>{offlinePrompt}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQuick && (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{defaultQuickQuestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => sendMessage(q)}
|
||||
disabled={!sessionId || sending || sessionEnded}
|
||||
className="px-3 py-1 rounded-full border border-[#2563eb] bg-white text-[#2563eb] text-xs cursor-pointer whitespace-nowrap hover:bg-[#eff6ff] disabled:opacity-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(msg => (
|
||||
msg.sender === 'visitor' ? (
|
||||
<div key={msg.id} className="flex flex-col items-end max-w-[85%] ml-auto gap-1">
|
||||
<div className={`rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||||
{msg.type === 'image' ? (
|
||||
<ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
|
||||
) : (
|
||||
<MarkdownBody tone="inverse">{msg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
{msg.time && (
|
||||
<span className="text-[11px] text-neutral-400 pr-0.5">{msg.time}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div key={msg.id} className="flex gap-2 max-w-[85%]">
|
||||
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<div className={`rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||||
{msg.type === 'image' ? (
|
||||
<ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
|
||||
) : (
|
||||
<MarkdownBody>{msg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
{msg.time && (
|
||||
<span className="text-[11px] text-neutral-400 pl-0.5">{msg.time}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{agentTyping && (
|
||||
<>
|
||||
<div className="flex gap-2 max-w-[85%]">
|
||||
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
||||
</div>
|
||||
<div className="px-4 py-3 rounded-xl bg-white border border-neutral-200 flex items-center gap-1">
|
||||
<span className="typing-dot" style={{ animationDelay: '0s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-left text-xs text-neutral-400 m-0 pl-[42px]">客服正在输入...</p>
|
||||
</>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
<MessageList
|
||||
showWelcome={showWelcome}
|
||||
agentsOnline={agentsOnline}
|
||||
welcomeSegments={welcomeSegments}
|
||||
offlinePrompt={offlinePrompt}
|
||||
showQuick={showQuick}
|
||||
messages={messages}
|
||||
agentTyping={agentTyping}
|
||||
sessionId={sessionId}
|
||||
sending={sending}
|
||||
sessionEnded={sessionEnded}
|
||||
onQuick={q => sendMessage(q)}
|
||||
chatEndRef={chatEndRef}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<footer className="shrink-0 px-4 py-3 bg-white border-t border-neutral-200">
|
||||
@@ -1128,198 +966,62 @@ const VisitorChat = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sessionEnded && pendingImage && agentsOnline && (
|
||||
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||||
<img src={pendingImage.preview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认上传并发送?(自动转 WebP)</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-neutral-400 border-0 bg-transparent cursor-pointer"
|
||||
onClick={() => {
|
||||
URL.revokeObjectURL(pendingImage.preview)
|
||||
setPendingImage(null)
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" disabled={sending} className="text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50" onClick={sendImage}>发送</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sessionEnded && agentsOnline && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||
aria-label="发送图片"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!sessionId || sending}
|
||||
>
|
||||
<PictureOutlined className="text-base" />
|
||||
</button>
|
||||
<EmojiPicker
|
||||
onSelect={insertEmoji}
|
||||
disabled={!sessionId || sending}
|
||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||
placement="topLeft"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={e => { handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sessionEnded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={textInputRef}
|
||||
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
||||
placeholder={
|
||||
!sessionId
|
||||
? '正在连接...'
|
||||
: agentsOnline
|
||||
? '输入消息...'
|
||||
: '描述您的问题(留言)'
|
||||
}
|
||||
value={input}
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
setInput(v)
|
||||
emitInputDraft(v, v === '')
|
||||
}}
|
||||
onPaste={e => {
|
||||
if (!agentsOnline) return
|
||||
const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
|
||||
if (item) {
|
||||
e.preventDefault()
|
||||
handleImageFile(item.getAsFile())
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
if (agentsOnline) sendMessage(input)
|
||||
}
|
||||
}}
|
||||
disabled={sending || !sessionId}
|
||||
/>
|
||||
{agentsOnline ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || sending || !sessionId}
|
||||
className="w-10 h-10 rounded-full border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer flex items-center justify-center shrink-0"
|
||||
aria-label="发送"
|
||||
>
|
||||
<SendOutlined className="text-white text-sm" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitLeaveMessage}
|
||||
disabled={sending || !sessionId}
|
||||
className="h-10 px-3 rounded-full border-0 bg-[#d97706] hover:bg-[#b45309] disabled:opacity-40 cursor-pointer text-white text-xs shrink-0"
|
||||
>
|
||||
{leaveSent ? '再留言' : '提交留言'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ChatComposer
|
||||
sessionEnded={sessionEnded}
|
||||
agentsOnline={agentsOnline}
|
||||
pendingImage={pendingImage}
|
||||
sending={sending}
|
||||
sessionId={sessionId}
|
||||
leaveSent={leaveSent}
|
||||
input={input}
|
||||
fileInputRef={fileInputRef}
|
||||
textInputRef={textInputRef}
|
||||
onCancelImage={() => {
|
||||
if (!pendingImage) return
|
||||
URL.revokeObjectURL(pendingImage.preview)
|
||||
setPendingImage(null)
|
||||
}}
|
||||
onSendImage={sendImage}
|
||||
onPickFile={handleImageFile}
|
||||
onInsertEmoji={insertEmoji}
|
||||
onInputChange={v => {
|
||||
setInput(v)
|
||||
emitInputDraft(v, v === '')
|
||||
}}
|
||||
onSend={() => sendMessage(input)}
|
||||
onSubmitLeave={submitLeaveMessage}
|
||||
/>
|
||||
</footer>
|
||||
|
||||
{showRating && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10 p-4">
|
||||
<div className="bg-white rounded-xl p-5 w-full max-w-xs text-center shadow-lg">
|
||||
<div className="text-lg font-semibold text-neutral-800 mb-1">本次服务如何?</div>
|
||||
<div className="text-sm text-neutral-400 mb-3">先点亮星级,可补充感受后再提交</div>
|
||||
<div className="flex justify-center gap-1.5 mb-1">
|
||||
{[1, 2, 3, 4, 5].map(star => {
|
||||
const active = star <= (hoverStar || ratingScore)
|
||||
return (
|
||||
<StarFilled
|
||||
key={star}
|
||||
className="text-2xl cursor-pointer transition-colors"
|
||||
style={{ color: active ? '#facc15' : '#e2e8f0' }}
|
||||
onMouseEnter={() => setHoverStar(star)}
|
||||
onMouseLeave={() => setHoverStar(0)}
|
||||
onClick={() => {
|
||||
setRatingScore(star)
|
||||
setSendError('')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400 mb-3 h-4">
|
||||
{ratingScore > 0 ? `${ratingScore} 星` : '请选择 1~5 星'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center mb-3">
|
||||
{ratingPresets.map(preset => {
|
||||
const selected = ratingText === preset
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setRatingText(prev => (prev === preset ? '' : preset))}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border cursor-pointer transition-colors ${
|
||||
selected
|
||||
? 'bg-[#2563eb] border-[#2563eb] text-white'
|
||||
: 'bg-neutral-50 border-neutral-200 text-neutral-600 hover:border-blue-300 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none text-left"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
value={ratingText}
|
||||
onChange={e => setRatingText(e.target.value)}
|
||||
placeholder="可选:点击上方标签快速填入,或自行输入"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={ratingSubmitting || ratingScore < 1}
|
||||
onClick={() => { void submitRating() }}
|
||||
className="w-full h-10 rounded-xl border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer text-white text-sm font-medium mb-2"
|
||||
>
|
||||
{ratingSubmitting ? '提交中...' : '提交评价'}
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setHoverStar(0)
|
||||
}}
|
||||
className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending || ratingSubmitting}
|
||||
onClick={() => {
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setHoverStar(0)
|
||||
void startNewSession()
|
||||
}}
|
||||
className="text-sm text-[#2563eb] hover:text-[#1d4ed8] border-0 bg-transparent cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
跳过并重新咨询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showRating && (
|
||||
<RatingOverlay
|
||||
score={ratingScore}
|
||||
hoverStar={hoverStar}
|
||||
text={ratingText}
|
||||
submitting={ratingSubmitting}
|
||||
sending={sending}
|
||||
presets={ratingPresets}
|
||||
onScore={star => {
|
||||
setRatingScore(star)
|
||||
setSendError('')
|
||||
}}
|
||||
onHover={setHoverStar}
|
||||
onText={setRatingText}
|
||||
onSubmit={() => { void submitRating() }}
|
||||
onTogglePreset={preset => setRatingText(prev => (prev === preset ? '' : preset))}
|
||||
onSkip={() => {
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setHoverStar(0)
|
||||
}}
|
||||
onSkipAndRestart={() => {
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setHoverStar(0)
|
||||
void startNewSession()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { SendOutlined, PictureOutlined } from '@ant-design/icons'
|
||||
import EmojiPicker from '@/components/common/EmojiPicker'
|
||||
|
||||
/** 底部输入区:图片预览条 + 图片/表情按钮 + 消息输入(或留言提交) */
|
||||
export function ChatComposer({
|
||||
sessionEnded,
|
||||
agentsOnline,
|
||||
pendingImage,
|
||||
sending,
|
||||
sessionId,
|
||||
leaveSent,
|
||||
input,
|
||||
fileInputRef,
|
||||
textInputRef,
|
||||
onCancelImage,
|
||||
onSendImage,
|
||||
onPickFile,
|
||||
onInsertEmoji,
|
||||
onInputChange,
|
||||
onSend,
|
||||
onSubmitLeave,
|
||||
}: {
|
||||
sessionEnded: boolean
|
||||
agentsOnline: boolean
|
||||
pendingImage: { file: File; preview: string } | null
|
||||
sending: boolean
|
||||
sessionId: number | null
|
||||
leaveSent: boolean
|
||||
input: string
|
||||
fileInputRef: RefObject<HTMLInputElement | null>
|
||||
textInputRef: RefObject<HTMLInputElement | null>
|
||||
onCancelImage: () => void
|
||||
onSendImage: () => void
|
||||
onPickFile: (file: File | null | undefined) => void
|
||||
onInsertEmoji: (emoji: string) => void
|
||||
onInputChange: (value: string) => void
|
||||
onSend: () => void
|
||||
onSubmitLeave: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{!sessionEnded && pendingImage && agentsOnline && (
|
||||
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||||
<img src={pendingImage.preview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认上传并发送?(自动转 WebP)</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-neutral-400 border-0 bg-transparent cursor-pointer"
|
||||
onClick={onCancelImage}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" disabled={sending} className="text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50" onClick={onSendImage}>发送</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sessionEnded && agentsOnline && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||
aria-label="发送图片"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!sessionId || sending}
|
||||
>
|
||||
<PictureOutlined className="text-base" />
|
||||
</button>
|
||||
<EmojiPicker
|
||||
onSelect={onInsertEmoji}
|
||||
disabled={!sessionId || sending}
|
||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||
placement="topLeft"
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={e => { onPickFile(e.target.files?.[0]); e.currentTarget.value = '' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sessionEnded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={textInputRef}
|
||||
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
||||
placeholder={
|
||||
!sessionId
|
||||
? '正在连接...'
|
||||
: agentsOnline
|
||||
? '输入消息...'
|
||||
: '描述您的问题(留言)'
|
||||
}
|
||||
value={input}
|
||||
onChange={e => onInputChange(e.target.value)}
|
||||
onPaste={e => {
|
||||
if (!agentsOnline) return
|
||||
const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
|
||||
if (item) {
|
||||
e.preventDefault()
|
||||
onPickFile(item.getAsFile())
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
if (agentsOnline) onSend()
|
||||
}
|
||||
}}
|
||||
disabled={sending || !sessionId}
|
||||
/>
|
||||
{agentsOnline ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || sending || !sessionId}
|
||||
className="w-10 h-10 rounded-full border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer flex items-center justify-center shrink-0"
|
||||
aria-label="发送"
|
||||
>
|
||||
<SendOutlined className="text-white text-sm" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSubmitLeave}
|
||||
disabled={sending || !sessionId}
|
||||
className="h-10 px-3 rounded-full border-0 bg-[#d97706] hover:bg-[#b45309] disabled:opacity-40 cursor-pointer text-white text-xs shrink-0"
|
||||
>
|
||||
{leaveSent ? '再留言' : '提交留言'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CloseOutlined, MinusOutlined, CustomerServiceOutlined } from '@ant-design/icons'
|
||||
|
||||
/** 聊天窗口顶部栏:品牌区 + 在线状态 + 关闭/最小化 */
|
||||
export function ChatHeader({
|
||||
displayName,
|
||||
sessionEnded,
|
||||
agentsOnline,
|
||||
agentName,
|
||||
isFill,
|
||||
onMinimize,
|
||||
onClose,
|
||||
}: {
|
||||
displayName: string
|
||||
sessionEnded: boolean
|
||||
agentsOnline: boolean
|
||||
agentName: string
|
||||
isFill: boolean
|
||||
onMinimize: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<header className="shrink-0 px-5 py-4 bg-gradient-to-r from-blue-600 via-blue-600 to-indigo-600 text-white flex items-center gap-3 shadow-sm">
|
||||
<div className="w-10 h-10 rounded-2xl bg-white/20 backdrop-blur-md flex items-center justify-center shrink-0 border border-white/20 shadow-inner">
|
||||
<CustomerServiceOutlined className="text-lg text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="m-0 text-sm font-bold leading-tight text-white">{displayName || '在线客服'}</h1>
|
||||
<p className="m-0 text-[11px] leading-normal text-white/90 flex items-center gap-1.5 mt-1 font-medium">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full ring-2 ring-white/30"
|
||||
style={{ background: sessionEnded ? '#94a3b8' : agentsOnline ? '#22c55e' : '#f59e0b' }}
|
||||
/>
|
||||
{sessionEnded
|
||||
? '会话已结束'
|
||||
: agentsOnline
|
||||
? (agentName ? `${agentName} 为您服务` : '客服在线,随时解答')
|
||||
: '暂无在线客服 · 欢迎留言'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isFill && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMinimize}
|
||||
className="w-7 h-7 rounded-xl bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white transition-colors"
|
||||
aria-label="最小化"
|
||||
>
|
||||
<MinusOutlined className="text-xs" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-7 h-7 rounded-xl bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseOutlined className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { RefObject } from 'react'
|
||||
import { CustomerServiceOutlined } from '@ant-design/icons'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import MarkdownBody from '@/components/common/MarkdownBody'
|
||||
import SafeHtml, { looksLikeHtml } from '@/components/common/SafeHtml'
|
||||
import { defaultQuickQuestions, type Message, type WelcomeSegment } from './types'
|
||||
|
||||
/** 消息区:欢迎语 / 快捷提问 / 消息气泡 / 输入中提示 */
|
||||
export function MessageList({
|
||||
showWelcome,
|
||||
agentsOnline,
|
||||
welcomeSegments,
|
||||
offlinePrompt,
|
||||
showQuick,
|
||||
messages,
|
||||
agentTyping,
|
||||
sessionId,
|
||||
sending,
|
||||
sessionEnded,
|
||||
onQuick,
|
||||
chatEndRef,
|
||||
}: {
|
||||
showWelcome: boolean
|
||||
agentsOnline: boolean
|
||||
welcomeSegments: WelcomeSegment[]
|
||||
offlinePrompt: string
|
||||
showQuick: boolean
|
||||
messages: Message[]
|
||||
agentTyping: boolean
|
||||
sessionId: number | null
|
||||
sending: boolean
|
||||
sessionEnded: boolean
|
||||
onQuick: (question: string) => void
|
||||
chatEndRef: RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{showWelcome && (
|
||||
<div className="flex flex-col items-start gap-2 max-w-[90%]">
|
||||
{agentsOnline ? (
|
||||
welcomeSegments.map((seg, i) =>
|
||||
seg.type === 'image' ? (
|
||||
<div
|
||||
key={`w-${i}`}
|
||||
className="rounded-xl bg-white border border-neutral-200 p-1.5"
|
||||
>
|
||||
<ChatImage
|
||||
src={seg.content}
|
||||
alt="欢迎图"
|
||||
className="max-w-[220px] max-h-[220px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
key={`w-${i}`}
|
||||
className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-700 leading-normal"
|
||||
>
|
||||
{looksLikeHtml(seg.content) ? (
|
||||
<SafeHtml html={seg.content} />
|
||||
) : (
|
||||
<MarkdownBody>{seg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)
|
||||
) : (
|
||||
<div className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-600 leading-normal">
|
||||
{looksLikeHtml(offlinePrompt) ? (
|
||||
<SafeHtml html={offlinePrompt} />
|
||||
) : (
|
||||
<MarkdownBody>{offlinePrompt}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQuick && (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{defaultQuickQuestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => onQuick(q)}
|
||||
disabled={!sessionId || sending || sessionEnded}
|
||||
className="px-3 py-1 rounded-full border border-[#2563eb] bg-white text-[#2563eb] text-xs cursor-pointer whitespace-nowrap hover:bg-[#eff6ff] disabled:opacity-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(msg => (
|
||||
msg.sender === 'visitor' ? (
|
||||
<div key={msg.id} className="flex flex-col items-end max-w-[85%] ml-auto gap-1">
|
||||
<div className={`rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||||
{msg.type === 'image' ? (
|
||||
<ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
|
||||
) : (
|
||||
<MarkdownBody tone="inverse">{msg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
{msg.time && (
|
||||
<span className="text-[11px] text-neutral-400 pr-0.5">{msg.time}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div key={msg.id} className="flex gap-2 max-w-[85%]">
|
||||
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex flex-col gap-1">
|
||||
<div className={`rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||||
{msg.type === 'image' ? (
|
||||
<ChatImage src={msg.content} alt="图片" className="max-w-[180px] max-h-[180px]" />
|
||||
) : (
|
||||
<MarkdownBody>{msg.content}</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
{msg.time && (
|
||||
<span className="text-[11px] text-neutral-400 pl-0.5">{msg.time}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{agentTyping && (
|
||||
<>
|
||||
<div className="flex gap-2 max-w-[85%]">
|
||||
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
||||
</div>
|
||||
<div className="px-4 py-3 rounded-xl bg-white border border-neutral-200 flex items-center gap-1">
|
||||
<span className="typing-dot" style={{ animationDelay: '0s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-left text-xs text-neutral-400 m-0 pl-[42px]">客服正在输入...</p>
|
||||
</>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { StarFilled } from '@ant-design/icons'
|
||||
|
||||
/** 评价浮层:星级 + 快捷评语 + 提交/跳过 */
|
||||
export function RatingOverlay({
|
||||
score,
|
||||
hoverStar,
|
||||
text,
|
||||
submitting,
|
||||
sending,
|
||||
presets,
|
||||
onScore,
|
||||
onHover,
|
||||
onText,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
onSkipAndRestart,
|
||||
onTogglePreset,
|
||||
}: {
|
||||
score: number
|
||||
hoverStar: number
|
||||
text: string
|
||||
submitting: boolean
|
||||
sending: boolean
|
||||
presets: string[]
|
||||
onScore: (star: number) => void
|
||||
onHover: (star: number) => void
|
||||
onText: (next: string) => void
|
||||
onSubmit: () => void
|
||||
onSkip: () => void
|
||||
onSkipAndRestart: () => void
|
||||
/** 快捷评语:重复点击同一标签取消选中 */
|
||||
onTogglePreset: (preset: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10 p-4">
|
||||
<div className="bg-white rounded-xl p-5 w-full max-w-xs text-center shadow-lg">
|
||||
<div className="text-lg font-semibold text-neutral-800 mb-1">本次服务如何?</div>
|
||||
<div className="text-sm text-neutral-400 mb-3">先点亮星级,可补充感受后再提交</div>
|
||||
<div className="flex justify-center gap-1.5 mb-1">
|
||||
{[1, 2, 3, 4, 5].map(star => {
|
||||
const active = star <= (hoverStar || score)
|
||||
return (
|
||||
<StarFilled
|
||||
key={star}
|
||||
className="text-2xl cursor-pointer transition-colors"
|
||||
style={{ color: active ? '#facc15' : '#e2e8f0' }}
|
||||
onMouseEnter={() => onHover(star)}
|
||||
onMouseLeave={() => onHover(0)}
|
||||
onClick={() => onScore(star)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400 mb-3 h-4">
|
||||
{score > 0 ? `${score} 星` : '请选择 1~5 星'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center mb-3">
|
||||
{presets.map(preset => {
|
||||
const selected = text === preset
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => onTogglePreset(preset)}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border cursor-pointer transition-colors ${
|
||||
selected
|
||||
? 'bg-[#2563eb] border-[#2563eb] text-white'
|
||||
: 'bg-neutral-50 border-neutral-200 text-neutral-600 hover:border-blue-300 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none text-left"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
value={text}
|
||||
onChange={e => onText(e.target.value)}
|
||||
placeholder="可选:点击上方标签快速填入,或自行输入"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={submitting || score < 1}
|
||||
onClick={onSubmit}
|
||||
className="w-full h-10 rounded-xl border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer text-white text-sm font-medium mb-2"
|
||||
>
|
||||
{submitting ? '提交中...' : '提交评价'}
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSkip}
|
||||
className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending || submitting}
|
||||
onClick={onSkipAndRestart}
|
||||
className="text-sm text-[#2563eb] hover:text-[#1d4ed8] border-0 bg-transparent cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
跳过并重新咨询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface Message {
|
||||
id: number
|
||||
sender: 'visitor' | 'agent'
|
||||
content: string
|
||||
time: string
|
||||
type?: 'text' | 'image'
|
||||
/** 服务端单调序号,用于增量同步 */
|
||||
seq?: number
|
||||
}
|
||||
|
||||
export const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||||
|
||||
export type WelcomeSegment = { type: 'text' | 'image'; content: string }
|
||||
|
||||
export const defaultWelcomeSegments: WelcomeSegment[] = [
|
||||
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
|
||||
]
|
||||
|
||||
export function parseWelcomeSegments(data: {
|
||||
welcome_messages?: unknown
|
||||
welcome_message?: string
|
||||
}): WelcomeSegment[] {
|
||||
if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) {
|
||||
const segs: WelcomeSegment[] = []
|
||||
for (const raw of data.welcome_messages) {
|
||||
if (!raw || typeof raw !== 'object') continue
|
||||
const item = raw as { type?: string; content?: string }
|
||||
const content = typeof item.content === 'string' ? item.content.trim() : ''
|
||||
if (!content) continue
|
||||
if (item.type === 'image') segs.push({ type: 'image', content })
|
||||
else segs.push({ type: 'text', content })
|
||||
}
|
||||
if (segs.length > 0) return segs
|
||||
}
|
||||
if (typeof data.welcome_message === 'string' && data.welcome_message.trim()) {
|
||||
return [{ type: 'text', content: data.welcome_message.trim() }]
|
||||
}
|
||||
return defaultWelcomeSegments
|
||||
}
|
||||
|
||||
export type LayoutMode = 'floating' | 'fill'
|
||||
|
||||
export type HostPageInfo = { url?: string; title?: string; referrer?: string }
|
||||
Reference in New Issue
Block a user