新增访客黑名单:支持拉黑 IP/设备并管理列表

工作台可按 IP 或设备拉黑并填写释放时间与原因;侧栏黑名单页查看与解除;进线自动拦截。
This commit is contained in:
yml2213
2026-07-19 00:43:55 +08:00
parent ada7533ffa
commit 1f92c35963
11 changed files with 838 additions and 5 deletions
+154 -4
View File
@@ -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)}