新增独立快捷回复模块(团队/个人)
从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 CSV 导入导出;管理页顶栏与侧栏对齐。
This commit is contained in:
@@ -2,6 +2,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
AppstoreOutlined, MessageOutlined, HistoryOutlined, TeamOutlined,
|
||||
FileTextOutlined, BarChartOutlined, SettingOutlined, LogoutOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
@@ -10,6 +11,7 @@ const menuItems = [
|
||||
{ key: '/agent/customers', icon: <TeamOutlined />, label: '客户管理' },
|
||||
{ key: '/agent/chat-history', icon: <HistoryOutlined />, label: '对话记录' },
|
||||
{ key: '/agent/knowledge', icon: <FileTextOutlined />, label: '知识库' },
|
||||
{ key: '/agent/quick-replies', icon: <ThunderboltOutlined />, label: '快捷回复' },
|
||||
{ key: '/agent/statistics', icon: <BarChartOutlined />, label: '数据统计' },
|
||||
{ key: '/agent/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button, Form, Input, Modal, Popconfirm, Select, Spin, Table, Tabs, Upload, message, Tag, Space,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, CloudUploadOutlined, DownloadOutlined,
|
||||
ThunderboltOutlined, CheckOutlined, StopOutlined, ImportOutlined, ExportOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createQuickReply, deleteQuickReply, downloadQuickReplyTemplate, exportQuickReplies,
|
||||
getQuickReplies, importQuickReplies, publishQuickReply, unpublishQuickReply, updateQuickReply,
|
||||
type QuickReply, type QuickReplyScope,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
const QuickReplies = () => {
|
||||
const { user } = useAuth()
|
||||
const canManageTeam = user?.role === 'admin' || user?.role === 'supervisor'
|
||||
|
||||
const [tab, setTab] = useState<QuickReplyScope>(canManageTeam ? 'team' : 'personal')
|
||||
const [list, setList] = useState<QuickReply[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [q, setQ] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string | undefined>()
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<QuickReply | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getQuickReplies({
|
||||
scope: tab,
|
||||
status: statusFilter,
|
||||
q: q.trim() || undefined,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
setList(Array.isArray(res.list) ? res.list : [])
|
||||
setTotal(res.total || 0)
|
||||
} catch (e) {
|
||||
setList([])
|
||||
setTotal(0)
|
||||
message.error(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [tab, statusFilter, q, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
status: tab === 'personal' ? 'published' : 'draft',
|
||||
shortcut: '',
|
||||
group_name: '',
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: QuickReply) => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
title: row.title,
|
||||
content: row.content,
|
||||
shortcut: row.shortcut || '',
|
||||
group_name: row.group_name || '',
|
||||
status: row.status,
|
||||
})
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async (values: {
|
||||
title: string
|
||||
content: string
|
||||
shortcut?: string
|
||||
group_name?: string
|
||||
status?: string
|
||||
}) => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
title: values.title.trim(),
|
||||
content: values.content.trim(),
|
||||
shortcut: values.shortcut?.trim() || '',
|
||||
group_name: values.group_name?.trim() || '',
|
||||
status: values.status,
|
||||
}
|
||||
if (editing) {
|
||||
await updateQuickReply(editing.id, payload)
|
||||
message.success('已更新')
|
||||
} else {
|
||||
await createQuickReply({ scope: tab, ...payload })
|
||||
message.success(tab === 'team' && payload.status === 'draft' ? '已暂存' : '已创建')
|
||||
}
|
||||
setModalOpen(false)
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row: QuickReply) => {
|
||||
try {
|
||||
await deleteQuickReply(row.id)
|
||||
message.success('已删除')
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePublish = async (row: QuickReply) => {
|
||||
try {
|
||||
await publishQuickReply(row.id)
|
||||
message.success('已发布同步,全员可用')
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '发布失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnpublish = async (row: QuickReply) => {
|
||||
try {
|
||||
await unpublishQuickReply(row.id)
|
||||
message.success('已下线为暂存')
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
await exportQuickReplies(tab)
|
||||
message.success('导出已开始')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleTemplate = async () => {
|
||||
try {
|
||||
await downloadQuickReplyTemplate()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '下载模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
const canEditRow = (row: QuickReply) => {
|
||||
if (row.scope === 'team') return canManageTeam
|
||||
return true
|
||||
}
|
||||
|
||||
const columns: ColumnsType<QuickReply> = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
ellipsis: true,
|
||||
render: (v: string) => <span className="text-neutral-600 text-sm">{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '输入码',
|
||||
dataIndex: 'shortcut',
|
||||
width: 100,
|
||||
render: (v: string) =>
|
||||
v ? <code className="text-xs bg-neutral-100 px-1.5 py-0.5 rounded">/{v}</code> : <span className="text-neutral-300">—</span>,
|
||||
},
|
||||
{
|
||||
title: '分组',
|
||||
dataIndex: 'group_name',
|
||||
width: 90,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) =>
|
||||
s === 'published' ? (
|
||||
<Tag color="success">已发布</Tag>
|
||||
) : (
|
||||
<Tag>暂存</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '使用',
|
||||
dataIndex: 'usage_count',
|
||||
width: 70,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: tab === 'team' && canManageTeam ? 220 : 120,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={4} wrap>
|
||||
{canEditRow(row) && (
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{tab === 'team' && canManageTeam && row.status === 'draft' && (
|
||||
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => void handlePublish(row)}>
|
||||
发布同步
|
||||
</Button>
|
||||
)}
|
||||
{tab === 'team' && canManageTeam && row.status === 'published' && (
|
||||
<Button type="link" size="small" icon={<StopOutlined />} onClick={() => void handleUnpublish(row)}>
|
||||
下线
|
||||
</Button>
|
||||
)}
|
||||
{canEditRow(row) && (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => void handleDelete(row)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const tabItems = [
|
||||
...(canManageTeam
|
||||
? [{
|
||||
key: 'team',
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<ThunderboltOutlined /> 团队快捷回复
|
||||
</span>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: 'personal',
|
||||
label: '我的快捷回复',
|
||||
},
|
||||
]
|
||||
|
||||
// agent 只能看个人
|
||||
const effectiveTab = canManageTeam ? tab : 'personal'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||
{/* 顶栏 56px — 与侧栏 Logo 区底边对齐(其它页统一) */}
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-neutral-200 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">快捷回复</h1>
|
||||
</div>
|
||||
<Space wrap size="small" className="shrink-0">
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => void handleTemplate()}>
|
||||
导入模板
|
||||
</Button>
|
||||
<Button size="small" icon={<ExportOutlined />} onClick={() => void handleExport()}>
|
||||
导出
|
||||
</Button>
|
||||
<Upload
|
||||
accept=".csv,text/csv"
|
||||
showUploadList={false}
|
||||
beforeUpload={async file => {
|
||||
try {
|
||||
const res = await importQuickReplies(file as File, effectiveTab, 'skip')
|
||||
const d = res.data
|
||||
message.success(
|
||||
`导入完成:新增 ${d?.created ?? 0},更新 ${d?.updated ?? 0},跳过 ${d?.skipped ?? 0}`,
|
||||
)
|
||||
if (d?.errors?.length) {
|
||||
message.warning(d.errors.slice(0, 3).join(';'))
|
||||
}
|
||||
await load()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导入失败')
|
||||
}
|
||||
return false
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<ImportOutlined />}>导入 CSV</Button>
|
||||
</Upload>
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建
|
||||
</Button>
|
||||
</Space>
|
||||
</header>
|
||||
|
||||
{/* 工具栏:Tab + 搜索 */}
|
||||
<div className="shrink-0 px-6 pt-2 pb-3 bg-white border-b border-neutral-200">
|
||||
<Tabs
|
||||
size="small"
|
||||
className="!mb-0 quick-reply-tabs"
|
||||
activeKey={effectiveTab}
|
||||
onChange={k => {
|
||||
setTab(k as QuickReplyScope)
|
||||
setPage(1)
|
||||
setStatusFilter(undefined)
|
||||
}}
|
||||
items={tabItems}
|
||||
tabBarStyle={{ marginBottom: 8 }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索标题、内容、输入码"
|
||||
className="!w-64"
|
||||
onSearch={v => {
|
||||
setQ(v)
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
className="!w-28"
|
||||
value={statusFilter}
|
||||
onChange={v => {
|
||||
setStatusFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'published', label: '已发布' },
|
||||
{ value: 'draft', label: '暂存' },
|
||||
]}
|
||||
/>
|
||||
{effectiveTab === 'team' && canManageTeam && (
|
||||
<span className="text-xs text-neutral-400">
|
||||
暂存仅管理可见;「发布同步」后全员可用 · 工作台输入 / 调用
|
||||
</span>
|
||||
)}
|
||||
{effectiveTab === 'personal' && (
|
||||
<span className="text-xs text-neutral-400">
|
||||
个人话术仅自己可见 · 工作台输入 / 或输入码调用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="bg-white rounded-xl border border-neutral-200 p-2">
|
||||
{loading && list.length === 0 ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑快捷回复' : `新建${effectiveTab === 'team' ? '团队' : '个人'}快捷回复`}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saving}
|
||||
okText={effectiveTab === 'team' && !editing ? '暂存' : '保存'}
|
||||
width={560}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={values => void handleSave(values)} className="mt-2">
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }, { max: 100 }]}>
|
||||
<Input placeholder="如:打招呼" maxLength={100} />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }, { max: 2000 }]}>
|
||||
<Input.TextArea rows={4} maxLength={2000} showCount placeholder="插入到输入框的正文" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="shortcut"
|
||||
label="输入码"
|
||||
extra="工作台输入 /nh 可快速填入;仅字母数字下划线短横线"
|
||||
rules={[
|
||||
{
|
||||
pattern: /^\/?[a-zA-Z0-9_-]*$/,
|
||||
message: '输入码格式无效',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="如 nh(可选)" maxLength={32} addonBefore="/" />
|
||||
</Form.Item>
|
||||
<Form.Item name="group_name" label="分组" rules={[{ max: 50 }]}>
|
||||
<Input placeholder="如:通用、售后" maxLength={50} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'draft', label: '暂存' },
|
||||
{ value: 'published', label: effectiveTab === 'team' ? '已发布(全员可用)' : '已发布' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{effectiveTab === 'team' && canManageTeam && (
|
||||
<p className="text-xs text-neutral-500 -mt-2 mb-0">
|
||||
也可先暂存,列表中点「发布同步」。团队话术无需复制到每个员工账户。
|
||||
</p>
|
||||
)}
|
||||
</Form>
|
||||
{effectiveTab === 'team' && canManageTeam && editing?.status === 'draft' && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
type="dashed"
|
||||
icon={<CloudUploadOutlined />}
|
||||
block
|
||||
onClick={async () => {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
setSaving(true)
|
||||
await updateQuickReply(editing.id, {
|
||||
title: values.title.trim(),
|
||||
content: values.content.trim(),
|
||||
shortcut: values.shortcut?.trim() || '',
|
||||
group_name: values.group_name?.trim() || '',
|
||||
status: 'draft',
|
||||
})
|
||||
await publishQuickReply(editing.id)
|
||||
message.success('已保存并发布同步')
|
||||
setModalOpen(false)
|
||||
await load()
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存并发布同步
|
||||
</Button>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default QuickReplies
|
||||
@@ -10,6 +10,7 @@ const Dashboard = lazy(() => import('@/pages/agent/Dashboard'))
|
||||
const ChatHistory = lazy(() => import('@/pages/agent/ChatHistory'))
|
||||
const Customers = lazy(() => import('@/pages/agent/Customers'))
|
||||
const Knowledge = lazy(() => import('@/pages/agent/Knowledge'))
|
||||
const QuickReplies = lazy(() => import('@/pages/agent/QuickReplies'))
|
||||
const Statistics = lazy(() => import('@/pages/agent/Statistics'))
|
||||
const Settings = lazy(() => import('@/pages/agent/Settings'))
|
||||
const AdminDashboard = lazy(() => import('@/pages/admin/Dashboard'))
|
||||
@@ -66,6 +67,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> },
|
||||
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
|
||||
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
|
||||
{ path: 'quick-replies', element: <Lazy><QuickReplies /></Lazy> },
|
||||
{
|
||||
element: <RequireSupervisor />,
|
||||
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
|
||||
|
||||
@@ -323,6 +323,89 @@ export const updateKnowledgeEntry = (id: number, data: Partial<KnowledgeEntry>)
|
||||
put<KnowledgeEntry>(`/knowledge/entries/${id}`, data)
|
||||
export const deleteKnowledgeEntry = (id: number) => del(`/knowledge/entries/${id}`)
|
||||
|
||||
// 快捷回复(团队 / 个人)
|
||||
export type QuickReplyScope = 'team' | 'personal'
|
||||
export interface QuickReply {
|
||||
id: number
|
||||
tenant_id: number
|
||||
scope: QuickReplyScope
|
||||
owner_user_id?: number | null
|
||||
title: string
|
||||
content: string
|
||||
shortcut: string
|
||||
group_name: string
|
||||
status: 'draft' | 'published' | string
|
||||
sort_order: number
|
||||
usage_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const getQuickReplies = (params?: {
|
||||
scope?: 'team' | 'personal' | 'all'
|
||||
status?: string
|
||||
q?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params?.scope) search.set('scope', params.scope)
|
||||
if (params?.status) search.set('status', params.status)
|
||||
if (params?.q) search.set('q', params.q)
|
||||
if (params?.page) search.set('page', String(params.page))
|
||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
const qs = search.toString()
|
||||
return getList<QuickReply>(`/quick-replies${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export const suggestQuickReplies = (prefix = '') => {
|
||||
const search = new URLSearchParams()
|
||||
if (prefix) search.set('prefix', prefix)
|
||||
const qs = search.toString()
|
||||
return get<QuickReply[]>(`/quick-replies/suggest${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export const createQuickReply = (data: {
|
||||
scope: QuickReplyScope
|
||||
title: string
|
||||
content: string
|
||||
shortcut?: string
|
||||
group_name?: string
|
||||
status?: string
|
||||
sort_order?: number
|
||||
}) => post<QuickReply>('/quick-replies', data)
|
||||
|
||||
export const updateQuickReply = (id: number, data: {
|
||||
title: string
|
||||
content: string
|
||||
shortcut?: string
|
||||
group_name?: string
|
||||
status?: string
|
||||
sort_order?: number
|
||||
}) => put<QuickReply>(`/quick-replies/${id}`, data)
|
||||
|
||||
export const deleteQuickReply = (id: number) => del(`/quick-replies/${id}`)
|
||||
export const publishQuickReply = (id: number) => post<QuickReply>(`/quick-replies/${id}/publish`, {})
|
||||
export const unpublishQuickReply = (id: number) => post<QuickReply>(`/quick-replies/${id}/unpublish`, {})
|
||||
export const useQuickReply = (id: number) => post<{ ok: boolean; usage_count: number }>(`/quick-replies/${id}/use`, {})
|
||||
|
||||
export const exportQuickReplies = (scope: QuickReplyScope) =>
|
||||
downloadFile(`/quick-replies/export?scope=${scope}`, `quick_replies_${scope}.csv`)
|
||||
|
||||
export const downloadQuickReplyTemplate = () =>
|
||||
downloadFile('/quick-replies/import-template', 'quick_replies_template.csv')
|
||||
|
||||
export const importQuickReplies = (file: File, scope: QuickReplyScope, onConflict: 'skip' | 'overwrite' = 'skip') => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('scope', scope)
|
||||
form.append('on_conflict', onConflict)
|
||||
return postForm<{ created: number; updated: number; skipped: number; errors: string[] }>(
|
||||
`/quick-replies/import?scope=${scope}`,
|
||||
form,
|
||||
)
|
||||
}
|
||||
|
||||
// Channels
|
||||
export const getChannels = () => get<Channel[]>('/channels')
|
||||
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
|
||||
|
||||
Reference in New Issue
Block a user