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

从知识库拆出快捷回复:支持团队暂存与发布同步、个人话术、输入码与工作台 / 调用,以及 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
@@ -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: '系统设置' },
]
+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>
)
}
+469
View File
@@ -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
+2
View File
@@ -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> }],
+83
View File
@@ -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)