新增独立快捷回复模块(团队/个人)

从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 CSV 导入导出;管理页顶栏与侧栏对齐。
This commit is contained in:
yml2213
2026-07-17 21:59:10 +08:00
parent 54d23bb595
commit c5f28dca1c
11 changed files with 1491 additions and 26 deletions
+189 -26
View File
@@ -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>
)
}