新增访客黑名单:支持拉黑 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
+2 -1
View File
@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import {
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
ThunderboltOutlined, DownOutlined,
ThunderboltOutlined, DownOutlined, StopOutlined,
} from '@ant-design/icons'
import { Dropdown, message as antMsg } from 'antd'
import { useAuth } from '@/stores/auth'
@@ -15,6 +15,7 @@ const menuItems = [
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' },
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' },
{ key: '/agent/blacklist', icon: <StopOutlined />, label: '黑名单' },
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' },
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
]
+235
View File
@@ -0,0 +1,235 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Input, Popconfirm, Select, Spin, Table, Tag, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ReloadOutlined, StopOutlined } from '@ant-design/icons'
import { getBlacklist, releaseBlacklist, type BlacklistEntry } from '@/services/api'
function isActive(entry: BlacklistEntry) {
if (!entry.expires_at) return true
return new Date(entry.expires_at).getTime() > Date.now()
}
function formatExpire(entry: BlacklistEntry) {
if (!entry.expires_at) return '长期'
const t = new Date(entry.expires_at)
if (Number.isNaN(t.getTime())) return '—'
const active = t.getTime() > Date.now()
const text = t.toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
return active ? text : `${text}(已过期)`
}
function maskValue(kind: string, value: string) {
if (kind === 'ip') {
const parts = value.split('.')
if (parts.length === 4) return `${parts[0]}.${parts[1]}.***.${parts[3]}`
return value
}
if (value.length <= 10) return value
return `${value.slice(0, 6)}${value.slice(-4)}`
}
const Blacklist = () => {
const [list, setList] = useState<BlacklistEntry[]>([])
const [loading, setLoading] = useState(false)
const [kind, setKind] = useState<string | undefined>()
const [showExpired, setShowExpired] = useState(false)
const [search, setSearch] = useState('')
const [releasingId, setReleasingId] = useState<number | null>(null)
const load = useCallback(async () => {
setLoading(true)
try {
const res = await getBlacklist({
kind: kind || undefined,
active: showExpired ? false : true,
})
setList(Array.isArray(res.data) ? res.data : [])
} catch (e) {
setList([])
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [kind, showExpired])
useEffect(() => {
void load()
}, [load])
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return list
return list.filter(item =>
item.value.toLowerCase().includes(q)
|| (item.reason || '').toLowerCase().includes(q),
)
}, [list, search])
const handleRelease = async (id: number) => {
setReleasingId(id)
try {
await releaseBlacklist(id)
message.success('已解除拉黑')
await load()
} catch (e) {
message.error(e instanceof Error ? e.message : '解除失败')
} finally {
setReleasingId(null)
}
}
const columns: ColumnsType<BlacklistEntry> = [
{
title: '类型',
dataIndex: 'kind',
width: 90,
render: (k: string) => (
<Tag color={k === 'ip' ? 'blue' : 'purple'}>
{k === 'ip' ? 'IP' : k === 'device' ? '设备' : k}
</Tag>
),
},
{
title: '目标',
dataIndex: 'value',
ellipsis: true,
render: (v: string, row) => (
<span className="font-mono text-[13px]" title={v}>
{maskValue(row.kind, v)}
</span>
),
},
{
title: '原因',
dataIndex: 'reason',
ellipsis: true,
render: (t: string) => t || '—',
},
{
title: '状态',
width: 90,
render: (_, row) => (
isActive(row)
? <Tag color="error"></Tag>
: <Tag></Tag>
),
},
{
title: '释放时间',
width: 180,
render: (_, row) => (
<span className="text-[13px] text-neutral-600">{formatExpire(row)}</span>
),
},
{
title: '拉黑时间',
dataIndex: 'created_at',
width: 170,
render: (t?: string) => t
? new Date(t).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
: '—',
},
{
title: '操作',
width: 100,
fixed: 'right',
render: (_, row) => (
isActive(row) ? (
<Popconfirm
title="确认解除该黑名单?"
description="解除后访客可重新进入在线客服"
onConfirm={() => handleRelease(row.id)}
okText="解除"
cancelText="取消"
>
<Button type="link" size="small" danger loading={releasingId === row.id}>
</Button>
</Popconfirm>
) : (
<span className="text-xs text-neutral-400"></span>
)
),
},
]
return (
<div className="h-full flex flex-col bg-[#f8fafc]">
<div className="shrink-0 bg-white border-b border-neutral-200 px-6 py-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<StopOutlined className="text-[#dc2626]" />
<h1 className="m-0 text-lg font-semibold text-neutral-900"></h1>
</div>
<p className="m-0 mt-1 text-sm text-neutral-500">
IP / 访
</p>
</div>
<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>
</Button>
</div>
<div className="flex flex-wrap items-center gap-2 mt-4">
<Select
allowClear
placeholder="全部类型"
className="w-32"
value={kind}
onChange={v => setKind(v)}
options={[
{ value: 'ip', label: 'IP' },
{ value: 'device', label: '设备' },
]}
/>
<Select
className="w-36"
value={showExpired ? 'all' : 'active'}
onChange={v => setShowExpired(v === 'all')}
options={[
{ value: 'active', label: '仅生效中' },
{ value: 'all', label: '含已过期' },
]}
/>
<Input.Search
allowClear
placeholder="搜索 IP / 设备 / 原因"
className="w-64"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
</div>
<div className="flex-1 min-h-0 p-6 overflow-auto">
<div className="bg-white rounded-xl border border-neutral-200 overflow-hidden">
{loading && list.length === 0 ? (
<div className="py-20 flex justify-center"><Spin /></div>
) : (
<Table
rowKey="id"
size="middle"
columns={columns}
dataSource={filtered}
pagination={{
pageSize: 20,
showTotal: t => `${t}`,
showSizeChanger: false,
}}
locale={{ emptyText: '暂无黑名单记录' }}
scroll={{ x: 900 }}
/>
)}
</div>
</div>
</div>
)
}
export default Blacklist
+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)}
+2
View File
@@ -11,6 +11,7 @@ const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
const Customers = lazy(() => import('@/pages/agent/Customers'))
const Knowledge = lazy(() => import('@/pages/agent/Knowledge'))
const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies'))
const Blacklist = lazy(() => import('@/pages/agent/Blacklist'))
const Statistics = lazy(() => import('@/pages/agent/Statistics'))
const Settings = lazy(() => import('@/pages/agent/Settings'))
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
@@ -68,6 +69,7 @@ export const router = createBrowserRouter([
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> },
{ path: 'blacklist', element: <Lazy><Blacklist /></Lazy> },
{
element: <RequireSupervisor />,
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
+37
View File
@@ -20,6 +20,8 @@ export interface Session {
visitor_ip?: string
visitor_region?: string
user_agent?: string
/** 访客设备指纹(用于设备拉黑) */
device_key?: string
landing_url?: string
landing_title?: string
referrer?: string
@@ -349,6 +351,41 @@ export const createCustomer = (data: Partial<Customer>) => post<Customer>('/cust
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
export const deleteCustomer = (id: number) => del(`/customers/${id}`)
// 黑名单
export interface BlacklistEntry {
id: number
tenant_id: number
kind: 'ip' | 'device' | string
value: string
reason: string
expires_at?: string | null
operator_id?: number
session_id?: number | null
customer_id?: number | null
created_at?: string
}
export type BlacklistDuration = '1h' | '6h' | '1d' | '7d' | '30d' | 'permanent'
export const createBlacklist = (data: {
session_id?: number
kind: 'ip' | 'device'
value?: string
duration: BlacklistDuration | string
reason: string
end_session?: boolean
}) => post<BlacklistEntry>('/blacklist', data)
export const getBlacklist = (params?: { kind?: string; active?: boolean }) => {
const search = new URLSearchParams()
if (params?.kind) search.set('kind', params.kind)
if (params?.active === false) search.set('active', '0')
const qs = search.toString()
return get<BlacklistEntry[]>(`/blacklist${qs ? `?${qs}` : ''}`)
}
export const releaseBlacklist = (id: number) => del(`/blacklist/${id}`)
// 客户标签库(管理员维护,全员可选)
export interface CustomerTag {
id: number
+17
View File
@@ -354,6 +354,22 @@ const VisitorChat = ({
}
}, [sessionEnded])
/** 访客端持久设备指纹(用于设备拉黑) */
const getOrCreateDeviceId = () => {
const key = 'kefu_device_id'
try {
let id = localStorage.getItem(key)
if (id && id.length >= 8) return id
id = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID().replace(/-/g, '')
: `d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`
localStorage.setItem(key, id)
return id
} catch {
return `d${Date.now().toString(36)}`
}
}
/** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */
const bootstrapNewSession = useCallback(async () => {
const page = hostPageRef.current
@@ -363,6 +379,7 @@ const VisitorChat = ({
body: JSON.stringify({
channel_key: channelKey,
visitor_name: '访客',
device_id: getOrCreateDeviceId(),
page_url: page.url || '',
page_title: page.title || '',
referrer: page.referrer || '',