-
-
-
-
会话 {selected.id}
- {statusLabels[selected.status] || selected.status}
-
-
客户ID: {selected.customer_id} · {new Date(selected.created_at).toLocaleString('zh-CN')}
-
- {selected.satisfaction_score && (
-
-
{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(5 - selected.satisfaction_score)}
-
满意度评分
-
- )}
-
-
- {detailLoading ?
: messages.length === 0 ?
: messages.map(message => (
-
-
-
{message.sender_type === 'agent' ? '客服' : '访客'}
-
{message.content}
-
{new Date(message.sent_at).toLocaleString('zh-CN')}
+
+
+
+
+
+ {customer?.name || `客户${selected.customer_id}`}
+
+ {statusLabels[selected.status] || selected.status}
+ {selected.priority === 'urgent' && 紧急}
+
+
+ 会话 #{selected.id}
+ {customer?.source ? ` · ${customer.source}` : ''}
+ {' · '}
+ {new Date(selected.created_at).toLocaleString('zh-CN')}
+ {selected.ended_at ? ` ~ ${new Date(selected.ended_at).toLocaleString('zh-CN')}` : ''}
- ))}
+ {selected.satisfaction_score != null && (
+
+
+ {'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(Math.max(0, 5 - selected.satisfaction_score))}
+
+
满意度评分
+ {selected.satisfaction_text && (
+
{selected.satisfaction_text}
+ )}
+
+ )}
+
+
+
+
消息记录
+
+ {detailLoading ? (
+
+ ) : messages.length === 0 ? (
+
+ ) : messages.map(message => {
+ const isAgent = message.sender_type === 'agent'
+ return (
+
+
+
+ {isAgent ? '客服' : '访客'}
+
+ {message.type === 'image' ? (
+

+ ) : (
+
{message.content}
+ )}
+
+ {new Date(message.sent_at).toLocaleString('zh-CN')}
+
+
+
+ )
+ })}
+
+
+
+ {events.length > 0 && (
+
+
操作记录
+
+ {events.slice().reverse().map(ev => (
+
+
+ {new Date(ev.created_at).toLocaleString('zh-CN')}
+
+ {ev.action}
+ {ev.detail}
+
+ ))}
+
+
+ )}
- ) :
选择一个对话查看详情
}
+ ) : (
+
+ )}
)
diff --git a/web/src/pages/agent/Customers.tsx b/web/src/pages/agent/Customers.tsx
index 1088755..71e3004 100644
--- a/web/src/pages/agent/Customers.tsx
+++ b/web/src/pages/agent/Customers.tsx
@@ -1,7 +1,14 @@
import { useState, useEffect } from 'react'
-import { Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty, message } from 'antd'
-import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
-import { getCustomers, type Customer } from '@/services/api'
+import {
+ Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty,
+ message, Modal, Form, Popconfirm,
+} from 'antd'
+import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons'
+import {
+ createCustomer, deleteCustomer, getCustomer, getCustomers, updateCustomer,
+ type Customer, type Session,
+} from '@/services/api'
+import { useAuth } from '@/stores/auth'
const statusMap: Record
= {
online: { color: 'green', text: '在线' },
@@ -9,19 +16,36 @@ const statusMap: Record = {
busy: { color: 'orange', text: '忙碌' },
}
+const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户']
const tagColors: Record = {
'VIP客户': 'gold', '新客户': 'blue', '活跃': 'green', '沉默': 'default', '企业客户': 'purple',
}
+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 Customers = () => {
+ const { user } = useAuth()
+ const canDelete = user?.role === 'admin' || user?.role === 'supervisor'
const [customers, setCustomers] = useState([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
- const [statusFilter, setStatusFilter] = useState([])
+ const [statusFilter, setStatusFilter] = useState()
const [selectedCustomer, setSelectedCustomer] = useState(null)
+ const [editingId, setEditingId] = useState(null)
+ const [historySessions, setHistorySessions] = useState([])
const [drawerOpen, setDrawerOpen] = useState(false)
+ const [editOpen, setEditOpen] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [form] = Form.useForm()
useEffect(() => {
loadCustomers()
@@ -30,7 +54,7 @@ const Customers = () => {
const loadCustomers = async () => {
setLoading(true)
try {
- const res = await getCustomers({ search, status: statusFilter[0], page })
+ const res = await getCustomers({ search, status: statusFilter, page, pageSize: 10 })
setCustomers(res.list)
setTotal(res.total)
} catch {
@@ -40,39 +64,164 @@ const Customers = () => {
}
}
- const parseTags = (tagsStr: string): string[] => {
- try { return JSON.parse(tagsStr) } catch { return [] }
+ const openDetail = async (record: Customer) => {
+ setSelectedCustomer(record)
+ setDrawerOpen(true)
+ setHistorySessions([])
+ try {
+ const res = await getCustomer(record.id)
+ setSelectedCustomer(res.data.customer)
+ setHistorySessions(Array.isArray(res.data.sessions) ? res.data.sessions : [])
+ } catch {
+ // keep list snapshot
+ }
+ }
+
+ const openCreate = () => {
+ setEditingId(null)
+ form.resetFields()
+ form.setFieldsValue({ status: 'offline', source: '手动录入', tags: [] })
+ setEditOpen(true)
+ }
+
+ const openEdit = (customer: Customer) => {
+ setEditingId(customer.id)
+ form.setFieldsValue({
+ name: customer.name,
+ phone: customer.phone,
+ email: customer.email,
+ source: customer.source,
+ status: customer.status,
+ tags: parseTags(customer.tags),
+ })
+ setEditOpen(true)
+ }
+
+ const handleSave = async (values: {
+ name: string; phone?: string; email?: string; source?: string; status?: string; tags?: string[]
+ }) => {
+ setSaving(true)
+ try {
+ const payload = {
+ name: values.name.trim(),
+ phone: values.phone?.trim() || '',
+ email: values.email?.trim() || '',
+ source: values.source?.trim() || '手动录入',
+ status: values.status || 'offline',
+ tags: JSON.stringify(values.tags || []),
+ }
+ if (editingId) {
+ const res = await updateCustomer(editingId, payload)
+ message.success('客户已更新')
+ setSelectedCustomer(res.data)
+ setEditOpen(false)
+ await loadCustomers()
+ if (drawerOpen) await openDetail(res.data)
+ } else {
+ await createCustomer(payload)
+ message.success('客户已创建')
+ setEditOpen(false)
+ setPage(1)
+ await loadCustomers()
+ }
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '保存失败')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteCustomer(id)
+ message.success('已删除')
+ if (selectedCustomer?.id === id) {
+ setDrawerOpen(false)
+ setSelectedCustomer(null)
+ }
+ await loadCustomers()
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '删除失败')
+ }
}
const columns = [
- { title: '客户名称', dataIndex: 'name', key: 'name', render: (text: string, record: Customer) => (
- { setSelectedCustomer(record); setDrawerOpen(true) }}>{text}
- )},
- { title: '联系方式', key: 'contact', render: (_: unknown, record: Customer) => (
-
- {record.phone &&
}
- {record.email &&
{record.email}
}
-
- )},
- { title: '标签', dataIndex: 'tags', key: 'tags', render: (tags: string) => (
- {parseTags(tags).map((t: string) => {t})}
- )},
- { title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => },
- { title: '来源', dataIndex: 'source', key: 'source', render: (t: string) => {t} },
+ {
+ title: '客户名称', dataIndex: 'name', key: 'name',
+ render: (text: string, record: Customer) => (
+ openDetail(record)}>{text}
+ ),
+ },
+ {
+ title: '联系方式', key: 'contact',
+ render: (_: unknown, record: Customer) => (
+
+ {record.phone &&
}
+ {record.email &&
{record.email}
}
+ {!record.phone && !record.email &&
—}
+
+ ),
+ },
+ {
+ title: '标签', dataIndex: 'tags', key: 'tags',
+ render: (tags: string) => (
+
+ {parseTags(tags).map((t: string) => {t})}
+
+ ),
+ },
+ {
+ title: '状态', dataIndex: 'status', key: 'status',
+ render: (s: string) => ,
+ },
+ {
+ title: '来源', dataIndex: 'source', key: 'source',
+ render: (t: string) => {t || '—'},
+ },
{ title: '对话次数', dataIndex: 'conversation_count', key: 'conversation_count', align: 'center' as const },
- { title: '最近联系', dataIndex: 'last_contact_at', key: 'last_contact_at', render: (t: string) => {t || '-'} },
+ {
+ title: '最近联系', dataIndex: 'last_contact_at', key: 'last_contact_at',
+ render: (t: string) => {t ? new Date(t).toLocaleString('zh-CN') : '—'},
+ },
+ {
+ title: '操作', key: 'actions', width: 140,
+ render: (_: unknown, record: Customer) => (
+
+ } onClick={e => { e.stopPropagation(); openEdit(record) }}>编辑
+ {canDelete && (
+ { e?.stopPropagation(); handleDelete(record.id) }}>
+ } onClick={e => e.stopPropagation()}>删除
+
+ )}
+
+ ),
+ },
]
return (
客户管理
- } onClick={() => message.info('新建客户')}>新增客户
+ } onClick={openCreate}>新增客户
- } placeholder="搜索客户名称、手机号、邮箱" value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-64" allowClear />
-
@@ -84,28 +233,101 @@ const Customers = () => {
loading={loading}
pagination={{ current: page, total, pageSize: 10, showTotal: t => `共 ${t} 个客户`, onChange: p => setPage(p) }}
locale={{ emptyText: }}
- onRow={record => ({ onClick: () => { setSelectedCustomer(record); setDrawerOpen(true) }, style: { cursor: 'pointer' } })}
+ onRow={record => ({ onClick: () => openDetail(record), style: { cursor: 'pointer' } })}
/>
-
setDrawerOpen(false)} width={400} extra={} size="small">编辑}>
+ setDrawerOpen(false)}
+ width={420}
+ extra={
+ selectedCustomer && (
+ } size="small" onClick={() => openEdit(selectedCustomer)}>
+ 编辑
+
+ )
+ }
+ >
{selectedCustomer && (
-
{selectedCustomer.name[0]}
+
+ {selectedCustomer.name[0]}
+
{selectedCustomer.name}
-
{selectedCustomer.source}
+
{selectedCustomer.source || '未知来源'}
- {selectedCustomer.phone || '-'}
- {selectedCustomer.email || '-'}
-
+ {selectedCustomer.phone || '—'}
+ {selectedCustomer.email || '—'}
+
+
+
+ {selectedCustomer.conversation_count}
+
+
+ {parseTags(selectedCustomer.tags).length === 0
+ ? '—'
+ : parseTags(selectedCustomer.tags).map(t => {t})}
+
+
+
+
+
历史会话
+
+ {historySessions.length === 0 ? (
+
暂无会话记录
+ ) : historySessions.map(s => (
+
+
+ 会话 #{s.id}
+ {s.status}
+
+
+ {s.last_message || new Date(s.created_at).toLocaleString('zh-CN')}
+
+
+ ))}
+
+
)}
+
+ setEditOpen(false)}
+ onOk={() => form.submit()}
+ confirmLoading={saving}
+ destroyOnClose
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ({ value: k, label: v.text }))} />
+
+
+ ({ value: t, label: t }))} placeholder="选择或输入标签" />
+
+
+
)
}
diff --git a/web/src/pages/agent/Knowledge.tsx b/web/src/pages/agent/Knowledge.tsx
index 2273882..06a4ab7 100644
--- a/web/src/pages/agent/Knowledge.tsx
+++ b/web/src/pages/agent/Knowledge.tsx
@@ -1,17 +1,34 @@
import { useState, useEffect } from 'react'
-import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress } from 'antd'
-import { SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined } from '@ant-design/icons'
-import { getKnowledgeCategories, getKnowledgeEntries, type KnowledgeEntry } from '@/services/api'
+import {
+ Tree, Table, Input, Button, Modal, Form, Select, Tag, Empty, Progress,
+ message, Popconfirm, Space,
+} from 'antd'
+import {
+ SearchOutlined, PlusOutlined, EditOutlined, FileTextOutlined, DeleteOutlined, FolderAddOutlined,
+} from '@ant-design/icons'
+import {
+ createKnowledgeCategory, createKnowledgeEntry, deleteKnowledgeEntry,
+ getKnowledgeCategories, getKnowledgeEntries, updateKnowledgeEntry,
+ type KnowledgeEntry,
+} from '@/services/api'
+import { useAuth } from '@/stores/auth'
const Knowledge = () => {
+ const { user } = useAuth()
+ const canManage = user?.role === 'admin' || user?.role === 'supervisor'
+
const [categories, setCategories] = useState<{ key: string; title: string; id: number }[]>([])
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true)
const [selectedCategory, setSelectedCategory] = useState('')
const [search, setSearch] = useState('')
+ const [statusFilter, setStatusFilter] = useState()
const [modalOpen, setModalOpen] = useState(false)
+ const [categoryModalOpen, setCategoryModalOpen] = useState(false)
const [editingEntry, setEditingEntry] = useState(null)
+ const [saving, setSaving] = useState(false)
const [form] = Form.useForm()
+ const [categoryForm] = Form.useForm()
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
@@ -21,68 +38,244 @@ const Knowledge = () => {
useEffect(() => {
loadEntries()
- }, [selectedCategory, search, page])
+ }, [selectedCategory, search, page, statusFilter])
const loadCategories = async () => {
try {
const res = await getKnowledgeCategories()
- setCategories((res.data as any[]).map((c: any) => ({ key: String(c.id), title: c.name, id: c.id })))
- } catch { /* fallback */ }
+ const list = Array.isArray(res.data) ? res.data : []
+ setCategories(list.map(c => ({ key: String(c.id), title: c.name, id: c.id })))
+ } catch {
+ setCategories([])
+ }
}
const loadEntries = async () => {
setLoading(true)
try {
- const res = await getKnowledgeEntries({ category_id: selectedCategory, search, page })
+ const res = await getKnowledgeEntries({
+ category_id: selectedCategory,
+ search,
+ status: statusFilter,
+ page,
+ pageSize: 10,
+ })
setEntries(res.list)
setTotal(res.total)
- } catch { setEntries([]) } finally { setLoading(false) }
+ } catch {
+ setEntries([])
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const openCreate = () => {
+ setEditingEntry(null)
+ form.resetFields()
+ form.setFieldsValue({
+ status: 'published',
+ category_id: selectedCategory ? Number(selectedCategory) : undefined,
+ })
+ setModalOpen(true)
+ }
+
+ const openEdit = (entry: KnowledgeEntry) => {
+ setEditingEntry(entry)
+ form.setFieldsValue({
+ title: entry.title,
+ content: entry.content,
+ status: entry.status,
+ category_id: entry.category_id,
+ })
+ setModalOpen(true)
+ }
+
+ const handleSave = async (values: { title: string; content: string; status: string; category_id: number }) => {
+ setSaving(true)
+ try {
+ if (editingEntry) {
+ await updateKnowledgeEntry(editingEntry.id, values)
+ message.success('条目已更新')
+ } else {
+ await createKnowledgeEntry(values)
+ message.success('条目已创建')
+ }
+ setModalOpen(false)
+ await loadEntries()
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '保存失败')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async (id: number) => {
+ try {
+ await deleteKnowledgeEntry(id)
+ message.success('已删除')
+ await loadEntries()
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '删除失败')
+ }
+ }
+
+ const handleCreateCategory = async (values: { name: string }) => {
+ try {
+ await createKnowledgeCategory({ name: values.name.trim() })
+ message.success('分类已创建')
+ setCategoryModalOpen(false)
+ categoryForm.resetFields()
+ await loadCategories()
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '创建分类失败')
+ }
}
const columns = [
- { title: '标题', dataIndex: 'title', key: 'title', render: (t: string) => {t} },
- { title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (s: string) => {s === 'published' ? '已发布' : '草稿'} },
- { title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 200, render: (c: number) => (
-
- )},
- { title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 120, render: (t: string) => {t ? new Date(t).toLocaleDateString() : '-'} },
- { title: '操作', key: 'actions', width: 100, render: () => }>编辑 },
+ {
+ title: '标题', dataIndex: 'title', key: 'title',
+ render: (t: string) => {t},
+ },
+ {
+ title: '状态', dataIndex: 'status', key: 'status', width: 90,
+ render: (s: string) => {s === 'published' ? '已发布' : '草稿'},
+ },
+ {
+ title: '使用频率', dataIndex: 'usage_count', key: 'usage_count', width: 180,
+ render: (c: number) => (
+
+ ),
+ },
+ {
+ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 120,
+ render: (t: string) => {t ? new Date(t).toLocaleDateString() : '—'},
+ },
+ {
+ title: '操作', key: 'actions', width: 140,
+ render: (_: unknown, record: KnowledgeEntry) => canManage ? (
+
+ } onClick={() => openEdit(record)}>编辑
+ handleDelete(record.id)}>
+ }>删除
+
+
+ ) : 只读,
+ },
]
return (
-
-
-
-
知识分类
+
+
+
+
+ 知识分类
+
+ {canManage && (
+
} onClick={() => setCategoryModalOpen(true)} />
+ )}
+
setSelectedCategory(keys.length > 0 ? (keys[0] as string) : '')}
+ onSelect={keys => { setSelectedCategory(keys.length > 0 ? String(keys[0]) : ''); setPage(1) }}
blockNode
+ className="flex-1 overflow-auto"
/>
+
-
-
-
知识库
-
} placeholder="搜索..." value={search} onChange={e => { setSearch(e.target.value); setPage(1) }} className="w-48" size="small" allowClear />
+
+
+
知识库
+ }
+ placeholder="搜索标题或内容"
+ value={search}
+ onChange={e => { setSearch(e.target.value); setPage(1) }}
+ className="w-48"
+ size="small"
+ allowClear
+ />
+ { setStatusFilter(v); setPage(1) }}
+ options={[
+ { value: 'published', label: '已发布' },
+ { value: 'draft', label: '草稿' },
+ ]}
+ />
-
} onClick={() => { setEditingEntry(null); form.resetFields(); setModalOpen(true) }}>新建条目
+ {canManage && (
+
} onClick={openCreate}>新建条目
+ )}
-
`共 ${t} 条`, onChange: p => setPage(p) }}
- locale={{ emptyText: }} />
+ locale={{ emptyText: }}
+ />
- setModalOpen(false)} onOk={() => form.submit()} width={640}>
-
-
-
+
+ setModalOpen(false)}
+ onOk={() => form.submit()}
+ confirmLoading={saving}
+ width={640}
+ destroyOnClose
+ >
+
+
+
+
+ ({ value: c.id, label: c.title }))}
+ placeholder={categories.length ? '选择分类' : '请先创建分类'}
+ />
+
+
+
+
+
+
+
+
+
+
+ setCategoryModalOpen(false)}
+ onOk={() => categoryForm.submit()}
+ destroyOnClose
+ >
+
+
+
diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx
index 7bc7577..c5dc586 100644
--- a/web/src/pages/agent/Settings.tsx
+++ b/web/src/pages/agent/Settings.tsx
@@ -1,19 +1,25 @@
-import { useState } from 'react'
-import { Form, Input, Switch, Select, Button, Card, Table, Tag, message } from 'antd'
-import { LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined } from '@ant-design/icons'
+import { useEffect, useState } from 'react'
+import { Form, Input, Switch, Select, Button, Card, Tag, message, Spin, Empty } from 'antd'
+import {
+ LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined,
+} from '@ant-design/icons'
+import { createChannel, getChannels, updateChannel, type Channel } from '@/services/api'
-const channelTypes = [
- { key: 'web', icon: , label: '网页聊天', status: true, code: 'WK_8a3f2e', script: '' },
- { key: 'wechat', icon: , label: '微信公众号', status: true, code: 'WX_c7b9d1', script: '' },
- { key: 'app', icon: , label: 'APP 内嵌', status: false, code: '', script: '' },
- { key: 'phone', icon: , label: '电话客服', status: false, code: '', script: '' },
- { key: 'email', icon: , label: '邮件工单', status: true, code: 'EM_f2a8c3', script: '' },
-]
+const typeMeta: Record = {
+ web: { icon: , label: '网页聊天' },
+ wechat: { icon: , label: '微信公众号' },
+ app: { icon: , label: 'APP 内嵌' },
+ phone: { icon: , label: '电话客服' },
+ email: { icon: , label: '邮件工单' },
+}
const Settings = () => {
- const [activeTab, setActiveTab] = useState('basic')
+ const [activeTab, setActiveTab] = useState('channels')
const [basicForm] = Form.useForm()
const [autoReplyForm] = Form.useForm()
+ const [channels, setChannels] = useState([])
+ const [loadingChannels, setLoadingChannels] = useState(false)
+ const [togglingId, setTogglingId] = useState(null)
const tabItems = [
{ key: 'basic', label: '基本设置' },
@@ -25,9 +31,63 @@ const Settings = () => {
{ key: 'notify', label: '通知设置' },
]
+ const loadChannels = async () => {
+ setLoadingChannels(true)
+ try {
+ const res = await getChannels()
+ setChannels(Array.isArray(res.data) ? res.data : [])
+ } catch {
+ setChannels([])
+ message.error('加载渠道失败')
+ } finally {
+ setLoadingChannels(false)
+ }
+ }
+
+ useEffect(() => {
+ if (activeTab === 'channels') loadChannels()
+ }, [activeTab])
+
+ 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('复制失败')
+ }
+ }
+
+ const toggleChannel = async (ch: Channel, enabled: boolean) => {
+ setTogglingId(ch.id)
+ try {
+ const res = await updateChannel(ch.id, { status: enabled ? 'enabled' : 'disabled' })
+ setChannels(prev => prev.map(c => c.id === ch.id ? res.data : c))
+ message.success(enabled ? '渠道已启用' : '渠道已停用')
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '更新失败')
+ } finally {
+ setTogglingId(null)
+ }
+ }
+
+ const ensureWebChannel = async () => {
+ try {
+ await createChannel({ type: 'web', name: '网页聊天' })
+ message.success('已创建网页渠道')
+ await loadChannels()
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '创建失败')
+ }
+ }
+
+ const missingTypes = Object.keys(typeMeta).filter(t => !channels.some(c => c.type === t))
+
return (
- {/* 左侧导航 */}
系统设置
@@ -35,7 +95,11 @@ const Settings = () => {
{tabItems.map(item => (
setActiveTab(item.key)}
>
{item.label}
@@ -43,9 +107,7 @@ const Settings = () => {
))}
- {/* 右侧内容 */}
- {/* 基本设置 */}
{activeTab === 'basic' && (
-
+
)}
- {/* 渠道管理 */}
{activeTab === 'channels' && (
-
渠道管理
+
+
渠道管理
+
配置接入渠道开关与网页聊天嵌入代码
+
+ {missingTypes.includes('web') && (
+
} onClick={ensureWebChannel}>开通网页渠道
+ )}
-
- {channelTypes.map(ch => (
-
- {ch.icon}
- {ch.label}
- {ch.status ? '已启用' : '未启用'}
-
- }>
-
- {ch.code &&
渠道ID{ch.code}
}
- {ch.script && (
-
-
接入代码
-
-
{ch.script}
-
message.success('已复制')} />
+
+ {loadingChannels ? (
+
+ ) : channels.length === 0 ? (
+
+
+
+ ) : (
+
+ {channels.map(ch => {
+ const meta = typeMeta[ch.type] || { icon: , label: ch.name || ch.type }
+ const enabled = ch.status === 'enabled'
+ return (
+
+ {meta.icon}
+ {ch.name || meta.label}
+ {enabled ? '已启用' : '未启用'}
+
+ )}
+ >
+
+ {ch.channel_key && (
+
+ 渠道 ID
+ {ch.channel_key}
+
+ )}
+ {ch.type === 'web' && ch.script_code && (
+
+
接入代码
+
+
+ {ch.script_code.replace('src="/widget.js"', `src="${window.location.origin}/widget.js"`)}
+
+ copyScript(ch.script_code)}
+ />
+
+
+ )}
+ {ch.type !== 'web' && (
+
+ 本期仅网页渠道可完整接入,其他渠道预留开关。
+
+ )}
+
+ 启用状态
+ toggleChannel(ch, v)}
+ />
- )}
-
- 启用状态
-
-
-
-
- ))}
-
+
+ )
+ })}
+
+ )}
+
+ {missingTypes.length > 0 && channels.length > 0 && (
+
+
可添加渠道类型
+
+ {missingTypes.map(t => (
+ }
+ onClick={async () => {
+ try {
+ await createChannel({ type: t, name: typeMeta[t].label })
+ message.success('已添加')
+ await loadChannels()
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '添加失败')
+ }
+ }}
+ >
+ {typeMeta[t].label}
+
+ ))}
+
+
+ )}
)}
- {/* 分配规则 */}
{activeTab === 'assignment' && (
-
+
+
当前生效:负载最低优先
+
+ 系统会在会话创建时,自动分配给当前「进行中会话」最少的在线客服。无在线客服时进入离线留言。
+
+
+
轮询分配
-
按在线客服顺序轮流分配会话,保证公平性
+
后续版本可切换
-
+
-
-
-
-
负载均衡
-
优先分配给当前会话数最少的客服
-
-
-
-
-
+
熟客优先
-
回头客优先分配给上次接待的客服
+
后续版本可切换
-
+
-
)}
- {/* 权限管理 */}
{activeTab === 'permission' && (
-
- {v ? '启用' : '禁用'} },
- { title: '操作', key: 'op', render: () => },
- ]}
- pagination={false}
- size="middle"
- />
+
+ 角色权限由系统内置(平台管理员 / 租户管理员 / 主管 / 一线客服),自定义角色将在后续版本开放。
)}
- {/* 自动回复 */}
{activeTab === 'autoreply' && (
-
-
)}
- {/* 工作时间 */}
{activeTab === 'worktime' && (
-
-
-
-
每周工作时段
- {['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => (
-
- {day}
- ({ value: v, label: v }))} />
-
- ))}
+
+ 工作时段配置将在后续版本接通,当前默认全天可接待。
+ {['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => (
+
+ {day}
+ ({ value: v, label: v }))}
+ />
-
-
-
+ ))}
)}
- {/* 通知设置 */}
{activeTab === 'notify' && (
-
- {[ { label: '新会话提醒', desc: '有新访客发起会话时桌面通知' },
- { label: '消息声音', desc: '收到新消息时播放提示音' },
- { label: '离线消息通知', desc: '非工作时间收到的留言邮件通知' },
- { label: '日报推送', desc: '每日客服数据汇总邮件' },
- { label: '周报推送', desc: '每周客服数据汇总邮件' },
- ].map((item, i) => (
-
+
+ {[
+ { title: '新会话提醒', desc: '有新访客进线时桌面通知' },
+ { title: '离线留言通知', desc: '访客提交离线留言时提醒' },
+ { title: '日报推送', desc: '每日服务数据摘要' },
+ ].map(item => (
+
-
{item.label}
+
{item.title}
{item.desc}
-
+
))}
+
通知推送配置将在后续版本接通。
)}
diff --git a/web/src/services/api.ts b/web/src/services/api.ts
index f86fa2f..85acafd 100644
--- a/web/src/services/api.ts
+++ b/web/src/services/api.ts
@@ -1,13 +1,14 @@
-import { get, post, put, getList } from './request'
+import { get, post, put, del, getList } from './request'
export interface LoginParams { username: string; password: string }
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
export interface Session {
- id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null
- status: string; priority: string; unread_count: number; satisfaction_score: number | null
- last_message?: string; last_message_at?: string | null
- created_at: string; ended_at: string | null
+ id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null
+ status: string; priority: string; unread_count: number; satisfaction_score: number | null
+ last_message?: string; last_message_at?: string | null
+ satisfaction_text?: string
+ created_at: string; ended_at: string | null
}
export interface Message {
@@ -26,11 +27,20 @@ export interface Customer {
source: string; status: string; conversation_count: number; last_contact_at: string
}
+export interface KnowledgeCategory {
+ id: number; tenant_id: number; parent_id: number | null; name: string
+}
+
export interface KnowledgeEntry {
id: number; title: string; content: string; status: string; usage_count: number
category_id: number; updated_at: string
}
+export interface Channel {
+ id: number; tenant_id: number; type: string; name: string; status: string
+ config: string; script_code: string; channel_key: string
+}
+
export interface Tenant {
id: number; name: string; plan_id: number; seat_count: number; expire_at: string; status: string
contact_name: string; contact_phone: string; contact_email: string
@@ -52,8 +62,8 @@ export const getSessions = (params?: { status?: string; priority?: string; page?
const search = new URLSearchParams()
if (params?.status) search.set('status', params.status)
if (params?.priority) search.set('priority', params.priority)
- if (params?.page) search.set('page', String(params.page))
- if (params?.pageSize) search.set('pageSize', String(params.pageSize))
+ if (params?.page) search.set('page', String(params.page))
+ if (params?.pageSize) search.set('pageSize', String(params.pageSize))
return getList
(`/sessions?${search}`)
}
export const getSession = (id: number) => get<{ session: Session; messages: Message[]; events: SessionEvent[]; pending_count: number }>(`/sessions/${id}`)
@@ -68,24 +78,42 @@ export const sendSessionMessage = (id: number, content: string, type: 'text' | '
export const getAvailableAgents = () => get('/agents/available')
// Customers
-export const getCustomers = (params?: { search?: string; status?: string; page?: number; pageSize?: number }) => {
+export const getCustomers = (params?: { search?: string; status?: string; source?: string; page?: number; pageSize?: number }) => {
const search = new URLSearchParams()
if (params?.search) search.set('search', params.search)
if (params?.status) search.set('status', params.status)
- if (params?.page) search.set('page', String(params.page || 1))
- if (params?.pageSize) search.set('pageSize', String(params.pageSize))
+ if (params?.source) search.set('source', params.source)
+ if (params?.page) search.set('page', String(params.page || 1))
+ if (params?.pageSize) search.set('pageSize', String(params.pageSize))
return getList(`/customers?${search}`)
}
+export const getCustomer = (id: number) => get<{ customer: Customer; sessions: Session[] }>(`/customers/${id}`)
+export const createCustomer = (data: Partial) => post('/customers', data)
+export const updateCustomer = (id: number, data: Partial) => put(`/customers/${id}`, data)
+export const deleteCustomer = (id: number) => del(`/customers/${id}`)
// Knowledge
-export const getKnowledgeCategories = () => get('/knowledge/categories')
-export const getKnowledgeEntries = (params?: { category_id?: string; search?: string; page?: number }) => {
+export const getKnowledgeCategories = () => get('/knowledge/categories')
+export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) => post('/knowledge/categories', data)
+export const getKnowledgeEntries = (params?: { category_id?: string; search?: string; status?: string; page?: number; pageSize?: number }) => {
const search = new URLSearchParams()
if (params?.category_id) search.set('category_id', params.category_id)
if (params?.search) search.set('search', params.search)
+ if (params?.status) search.set('status', params.status)
if (params?.page) search.set('page', String(params.page || 1))
+ if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10))
return getList(`/knowledge/entries?${search}`)
}
+export const createKnowledgeEntry = (data: { title: string; content: string; category_id: number; status?: string }) =>
+ post('/knowledge/entries', data)
+export const updateKnowledgeEntry = (id: number, data: Partial) =>
+ put(`/knowledge/entries/${id}`, data)
+export const deleteKnowledgeEntry = (id: number) => del(`/knowledge/entries/${id}`)
+
+// Channels
+export const getChannels = () => get('/channels')
+export const createChannel = (data: { type: string; name?: string }) => post('/channels', data)
+export const updateChannel = (id: number, data: { name?: string; status?: string }) => put(`/channels/${id}`, data)
// Statistics
export const getKPIs = () => get('/statistics/kpi')