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:
yml2213
2026-09-05 14:40:14 +08:00
parent 10d2e2ed3d
commit 6304f97bc3
27 changed files with 3051 additions and 2312 deletions
+54 -701
View File
@@ -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>
)
}
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>
)
}
+156
View File
@@ -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
}
}
+427
View File
@@ -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(/&nbsp;/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
+100
View File
@@ -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
+90
View File
@@ -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: '邮件自动创建工单(预留)' },
}
+46
View File
@@ -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)
+17
View File
@@ -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' }
}
+7
View File
@@ -0,0 +1,7 @@
import { updateTenantSettings } from '@/services/api'
/** 局部保存租户设置(与 Settings 入口文件 savePartial 的签名一致) */
export type SaveSettingsFn = (
payload: Parameters<typeof updateTenantSettings>[0],
okText?: string,
) => Promise<void>
+16
View File
@@ -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: '您好!欢迎咨询,请问有什么可以帮您?' }]
}
+59
View File
@@ -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
View File
@@ -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>
)
+138
View File
@@ -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>
)}
</>
)
}
+62
View File
@@ -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>
)
}
+147
View File
@@ -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} />
</>
)
}
+112
View File
@@ -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>
)
}
+43
View File
@@ -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 }