新增独立快捷回复模块(团队/个人)
从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 CSV 导入导出;管理页顶栏与侧栏对齐。
This commit is contained in:
@@ -3,16 +3,17 @@ import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popove
|
||||
import {
|
||||
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
||||
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
|
||||
ExportOutlined, BookOutlined, PictureOutlined,
|
||||
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import {
|
||||
addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries,
|
||||
getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, transferSession, updateSessionPriority,
|
||||
uploadImage,
|
||||
type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type Session, type SessionEvent,
|
||||
getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage,
|
||||
suggestQuickReplies, transferSession, updateSessionPriority, uploadImage, useQuickReply,
|
||||
type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type QuickReply,
|
||||
type Session, type SessionEvent,
|
||||
} from '@/services/api'
|
||||
|
||||
/** 按 id 合并消息,再按 seq / id 排序 */
|
||||
@@ -51,12 +52,6 @@ const endReasons = [
|
||||
{ value: 'transferred', label: '已转接' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
const quickReplies = [
|
||||
'您好,正在为您查询,请稍候。',
|
||||
'感谢您的耐心等待,还有什么可以帮您?',
|
||||
'为更快处理,请您提供订单号或截图。',
|
||||
]
|
||||
|
||||
/** 列表项展示:紧急 / 等待中 / 进行中 */
|
||||
function listStatusMeta(session: Session) {
|
||||
if (session.priority === 'urgent') {
|
||||
@@ -142,6 +137,15 @@ const Dashboard = () => {
|
||||
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 [slashOpen, setSlashOpen] = useState(false)
|
||||
const [slashPrefix, setSlashPrefix] = useState('')
|
||||
const [slashItems, setSlashItems] = useState<QuickReply[]>([])
|
||||
const [slashIndex, setSlashIndex] = useState(0)
|
||||
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
|
||||
const [noteInput, setNoteInput] = useState('')
|
||||
const [savingNote, setSavingNote] = useState(false)
|
||||
@@ -482,6 +486,64 @@ const Dashboard = () => {
|
||||
}).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(() => {
|
||||
if (!slashOpen) return
|
||||
let cancelled = false
|
||||
suggestQuickReplies(slashPrefix).then(res => {
|
||||
if (cancelled) return
|
||||
const list = Array.isArray(res.data) ? res.data : []
|
||||
setSlashItems(list)
|
||||
setSlashIndex(0)
|
||||
}).catch(() => {
|
||||
if (!cancelled) setSlashItems([])
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [slashOpen, slashPrefix])
|
||||
|
||||
const applyQuickReply = useCallback(async (item: QuickReply) => {
|
||||
setMessageInput(item.content)
|
||||
setQuickOpen(false)
|
||||
setSlashOpen(false)
|
||||
setSlashPrefix('')
|
||||
try {
|
||||
await useQuickReply(item.id)
|
||||
} catch { /* 计数失败可忽略 */ }
|
||||
requestAnimationFrame(() => {
|
||||
const el = messageInputRef.current
|
||||
if (el) {
|
||||
el.focus()
|
||||
const len = item.content.length
|
||||
el.setSelectionRange(len, len)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
/** 从输入内容解析末尾 /shortcut 触发 */
|
||||
const syncSlashFromInput = useCallback((value: string) => {
|
||||
// 匹配末尾未完成的 /xxx(前面是行首或空白)
|
||||
const m = /(^|[\s\n])\/([a-zA-Z0-9_-]*)$/.exec(value)
|
||||
if (m) {
|
||||
setSlashOpen(true)
|
||||
setSlashPrefix(m[2] || '')
|
||||
} else {
|
||||
setSlashOpen(false)
|
||||
setSlashPrefix('')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selected = sessions.find(session => session.id === selectedId)
|
||||
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
||||
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
|
||||
@@ -962,6 +1024,9 @@ const Dashboard = () => {
|
||||
<FlagOutlined />
|
||||
</button>
|
||||
</Dropdown>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="快捷回复" disabled={!canOperate} onClick={() => setQuickOpen(true)}>
|
||||
<ThunderboltOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="知识库" disabled={!canOperate} onClick={() => setKnowledgeOpen(true)}>
|
||||
<BookOutlined />
|
||||
</button>
|
||||
@@ -1083,32 +1148,65 @@ const Dashboard = () => {
|
||||
<PaperClipOutlined />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: quickReplies.map((content, index) => ({
|
||||
key: String(index),
|
||||
label: content,
|
||||
onClick: () => setMessageInput(content),
|
||||
})),
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 flex items-center gap-1"
|
||||
onClick={() => setQuickOpen(true)}
|
||||
>
|
||||
<button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100">
|
||||
快捷回复
|
||||
</button>
|
||||
</Dropdown>
|
||||
<ThunderboltOutlined />
|
||||
快捷回复
|
||||
</button>
|
||||
<button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 flex items-center gap-1" onClick={() => setKnowledgeOpen(true)}>
|
||||
<FileTextOutlined />
|
||||
知识库
|
||||
</button>
|
||||
<span className="text-[11px] text-neutral-400 ml-1">输入 / 调用话术</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-2.5 px-4 pb-3 pt-1">
|
||||
<div className="relative flex items-end gap-2.5 px-4 pb-3 pt-1">
|
||||
{slashOpen && (
|
||||
<div className="absolute bottom-full left-4 right-16 mb-1 z-20 max-h-56 overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg">
|
||||
{slashItems.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-neutral-400">无匹配话术</div>
|
||||
) : (
|
||||
slashItems.map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`w-full text-left px-3 py-2 border-0 cursor-pointer ${
|
||||
idx === slashIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
|
||||
}`}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
void applyQuickReply(item)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-neutral-800 truncate">{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded shrink-0">/{item.shortcut}</code>
|
||||
)}
|
||||
<span className="text-[10px] text-neutral-400 shrink-0">
|
||||
{item.scope === 'team' ? '团队' : '个人'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 line-clamp-1 mt-0.5">{item.content}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={messageInputRef}
|
||||
rows={3}
|
||||
className="flex-1 min-w-0 min-h-[88px] max-h-[160px] rounded-xl px-3.5 py-3 bg-neutral-50 border border-neutral-200 text-sm leading-6 text-neutral-800 placeholder:text-neutral-400 outline-none resize-y focus:border-[#2563eb] transition-colors"
|
||||
placeholder="输入回复内容… Enter 发送,Shift+Enter 换行"
|
||||
placeholder="输入回复内容… / 调话术,Enter 发送,Shift+Enter 换行"
|
||||
value={messageInput}
|
||||
onChange={event => { setMessageInput(event.target.value); emitTyping() }}
|
||||
onChange={event => {
|
||||
const v = event.target.value
|
||||
setMessageInput(v)
|
||||
syncSlashFromInput(v)
|
||||
emitTyping()
|
||||
}}
|
||||
onPaste={event => {
|
||||
const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/'))
|
||||
if (image) {
|
||||
@@ -1117,6 +1215,33 @@ const Dashboard = () => {
|
||||
}
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (slashOpen && slashItems.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSlashIndex(i => (i + 1) % slashItems.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSlashIndex(i => (i - 1 + slashItems.length) % slashItems.length)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
setSlashOpen(false)
|
||||
return
|
||||
}
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
|
||||
return
|
||||
}
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
sendMessage(messageInput)
|
||||
@@ -1362,7 +1487,7 @@ 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}>
|
||||
<Modal title="知识库" open={knowledgeOpen} onCancel={() => setKnowledgeOpen(false)} footer={null} width={640}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索标题或内容"
|
||||
@@ -1391,6 +1516,44 @@ const Dashboard = () => {
|
||||
</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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user