新增访客黑名单:支持拉黑 IP/设备并管理列表
工作台可按 IP 或设备拉黑并填写释放时间与原因;侧栏黑名单页查看与解除;进线自动拦截。
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popover } from 'antd'
|
||||
import { Button, Checkbox, Dropdown, Input, Modal, Radio, Select, Spin, message as antMsg, Popover } from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
||||
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
|
||||
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import {
|
||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
||||
addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries,
|
||||
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
||||
suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply,
|
||||
type AvailableAgent, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
|
||||
type AvailableAgent, type BlacklistDuration, type Customer, type CustomerContact, type KnowledgeEntry, type Message, type QuickReply,
|
||||
type Session, type SessionEvent, type VisitorPageView,
|
||||
} from '@/services/api'
|
||||
|
||||
@@ -257,6 +258,12 @@ const Dashboard = () => {
|
||||
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)
|
||||
@@ -857,7 +864,8 @@ const Dashboard = () => {
|
||||
|| event.action === 'assign'
|
||||
|| event.action === 'auto_assign'
|
||||
|| event.action === 'end'
|
||||
|| event.action === 'offline_leave',
|
||||
|| event.action === 'offline_leave'
|
||||
|| event.action === 'blacklist',
|
||||
)
|
||||
const notes = detail?.events
|
||||
.filter(event =>
|
||||
@@ -866,6 +874,7 @@ const Dashboard = () => {
|
||||
|| event.action === 'auto_assign'
|
||||
|| event.action === 'assign'
|
||||
|| event.action === 'transfer'
|
||||
|| event.action === 'blacklist'
|
||||
|| event.action === 'end',
|
||||
)
|
||||
.slice()
|
||||
@@ -890,6 +899,7 @@ const Dashboard = () => {
|
||||
const eventLabel = (action: string) => {
|
||||
switch (action) {
|
||||
case 'transfer': return '会话转接'
|
||||
case 'blacklist': return '加入黑名单'
|
||||
case 'assign': return '人工分配'
|
||||
case 'auto_assign': return '自动分配'
|
||||
case 'end': return '结束会话'
|
||||
@@ -1007,6 +1017,55 @@ const Dashboard = () => {
|
||||
}
|
||||
}
|
||||
|
||||
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 handlePriority = async (priority: 'normal' | 'urgent') => {
|
||||
if (!selected) return
|
||||
try {
|
||||
@@ -1312,6 +1371,15 @@ const Dashboard = () => {
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="转接" disabled={!canOperate} onClick={openTransfer}>
|
||||
<SwapOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-40"
|
||||
title="拉黑"
|
||||
disabled={!selected}
|
||||
onClick={openBlacklist}
|
||||
>
|
||||
<StopOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-red-500 hover:bg-red-50 disabled:opacity-40" title="结束会话" disabled={!canOperate} onClick={() => setEndingOpen(true)}>
|
||||
<CheckCircleOutlined />
|
||||
</button>
|
||||
@@ -1965,6 +2033,88 @@ const Dashboard = () => {
|
||||
<p className="text-sm text-neutral-500 mb-3">请选择本次会话的结束原因。</p>
|
||||
<Select className="w-full" value={endReason} onChange={setEndReason} options={endReasons} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title="拉黑访客"
|
||||
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>
|
||||
<Modal
|
||||
title="图片预览"
|
||||
open={Boolean(pendingImage)}
|
||||
|
||||
Reference in New Issue
Block a user