实现 P1 客户/知识库/对话记录与渠道设置
- 客户管理接通创建、编辑、删除与详情历史会话 - 知识库接通分类/条目 CRUD,按角色控制写权限 - 对话记录增强筛选、客户名、消息与操作时间线 - 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
This commit is contained in:
+219
-113
@@ -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: <LinkOutlined />, label: '网页聊天', status: true, code: 'WK_8a3f2e', script: '<script src="https://cs.example.com/widget.js" data-id="WK_8a3f2e"></script>' },
|
||||
{ key: 'wechat', icon: <WechatOutlined />, label: '微信公众号', status: true, code: 'WX_c7b9d1', script: '' },
|
||||
{ key: 'app', icon: <MobileOutlined />, label: 'APP 内嵌', status: false, code: '', script: '' },
|
||||
{ key: 'phone', icon: <PhoneOutlined />, label: '电话客服', status: false, code: '', script: '' },
|
||||
{ key: 'email', icon: <MailOutlined />, label: '邮件工单', status: true, code: 'EM_f2a8c3', script: '' },
|
||||
]
|
||||
const typeMeta: Record<string, { icon: React.ReactNode; label: string }> = {
|
||||
web: { icon: <LinkOutlined />, label: '网页聊天' },
|
||||
wechat: { icon: <WechatOutlined />, label: '微信公众号' },
|
||||
app: { icon: <MobileOutlined />, label: 'APP 内嵌' },
|
||||
phone: { icon: <PhoneOutlined />, label: '电话客服' },
|
||||
email: { icon: <MailOutlined />, 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<Channel[]>([])
|
||||
const [loadingChannels, setLoadingChannels] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(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 (
|
||||
<div className="h-full flex">
|
||||
{/* 左侧导航 */}
|
||||
<div className="w-[200px] flex-shrink-0 bg-white border-r border-neutral-200 py-4">
|
||||
<div className="px-4 mb-3">
|
||||
<span className="text-xs text-neutral-400 font-medium">系统设置</span>
|
||||
@@ -35,7 +95,11 @@ const Settings = () => {
|
||||
{tabItems.map(item => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`px-4 py-2 text-sm cursor-pointer transition-colors ${activeTab === item.key ? 'bg-blue-50 text-blue-600 font-medium border-r-2 border-blue-500' : 'text-neutral-600 hover:bg-neutral-50'}`}
|
||||
className={`px-4 py-2 text-sm cursor-pointer transition-colors ${
|
||||
activeTab === item.key
|
||||
? 'bg-blue-50 text-blue-600 font-medium border-r-2 border-blue-500'
|
||||
: 'text-neutral-600 hover:bg-neutral-50'
|
||||
}`}
|
||||
onClick={() => setActiveTab(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
@@ -43,9 +107,7 @@ const Settings = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{/* 基本设置 */}
|
||||
{activeTab === 'basic' && (
|
||||
<Card title="基本设置" className="max-w-2xl">
|
||||
<Form form={basicForm} layout="vertical" initialValues={{ name: '示例公司', nickname: '客服小助手', timezone: 'Asia/Shanghai', language: 'zh-CN' }}>
|
||||
@@ -62,164 +124,208 @@ const Settings = () => {
|
||||
<Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={() => message.success('保存成功')}>保存设置</Button>
|
||||
<Button type="primary" onClick={() => message.info('租户基本资料接口将在后续版本接通')}>保存设置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 渠道管理 */}
|
||||
{activeTab === 'channels' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-semibold text-neutral-800">渠道管理</h3>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-neutral-800 m-0">渠道管理</h3>
|
||||
<p className="text-xs text-neutral-400 mt-1 mb-0">配置接入渠道开关与网页聊天嵌入代码</p>
|
||||
</div>
|
||||
{missingTypes.includes('web') && (
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={ensureWebChannel}>开通网页渠道</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{channelTypes.map(ch => (
|
||||
<Card key={ch.key} size="small" className="!rounded-lg" title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-500">{ch.icon}</span>
|
||||
<span className="text-sm font-medium">{ch.label}</span>
|
||||
<Tag color={ch.status ? 'green' : 'default'} className="ml-2">{ch.status ? '已启用' : '未启用'}</Tag>
|
||||
</div>
|
||||
}>
|
||||
<div className="space-y-3">
|
||||
{ch.code && <div className="flex items-center justify-between text-sm"><span className="text-neutral-400">渠道ID</span><code className="text-xs bg-neutral-100 px-2 py-0.5 rounded">{ch.code}</code></div>}
|
||||
{ch.script && (
|
||||
<div>
|
||||
<div className="text-sm text-neutral-400 mb-1">接入代码</div>
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded p-2 flex items-center justify-between">
|
||||
<code className="text-xs text-neutral-600 break-all">{ch.script}</code>
|
||||
<CopyOutlined className="text-neutral-400 cursor-pointer hover:text-blue-500 ml-2 flex-shrink-0" onClick={() => message.success('已复制')} />
|
||||
|
||||
{loadingChannels ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : channels.length === 0 ? (
|
||||
<Empty description="暂无渠道,请先开通网页渠道">
|
||||
<Button type="primary" onClick={ensureWebChannel}>开通网页渠道</Button>
|
||||
</Empty>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{channels.map(ch => {
|
||||
const meta = typeMeta[ch.type] || { icon: <LinkOutlined />, label: ch.name || ch.type }
|
||||
const enabled = ch.status === 'enabled'
|
||||
return (
|
||||
<Card
|
||||
key={ch.id}
|
||||
size="small"
|
||||
className="!rounded-lg"
|
||||
title={(
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-blue-500">{meta.icon}</span>
|
||||
<span className="text-sm font-medium">{ch.name || meta.label}</span>
|
||||
<Tag color={enabled ? 'green' : 'default'} className="ml-1">{enabled ? '已启用' : '未启用'}</Tag>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{ch.channel_key && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-neutral-400">渠道 ID</span>
|
||||
<code className="text-xs bg-neutral-100 px-2 py-0.5 rounded">{ch.channel_key}</code>
|
||||
</div>
|
||||
)}
|
||||
{ch.type === 'web' && ch.script_code && (
|
||||
<div>
|
||||
<div className="text-sm text-neutral-400 mb-1">接入代码</div>
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded p-2 flex items-start justify-between gap-2">
|
||||
<code className="text-xs text-neutral-600 break-all">
|
||||
{ch.script_code.replace('src="/widget.js"', `src="${window.location.origin}/widget.js"`)}
|
||||
</code>
|
||||
<CopyOutlined
|
||||
className="text-neutral-400 cursor-pointer hover:text-blue-500 flex-shrink-0 mt-0.5"
|
||||
onClick={() => copyScript(ch.script_code)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ch.type !== 'web' && (
|
||||
<div className="text-xs text-neutral-400">
|
||||
本期仅网页渠道可完整接入,其他渠道预留开关。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-neutral-400">启用状态</span>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
size="small"
|
||||
loading={togglingId === ch.id}
|
||||
onChange={v => toggleChannel(ch, v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-neutral-400">启用状态</span>
|
||||
<Switch defaultChecked={ch.status} size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missingTypes.length > 0 && channels.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-xs text-neutral-400 mb-2">可添加渠道类型</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{missingTypes.map(t => (
|
||||
<Button
|
||||
key={t}
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
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}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 分配规则 */}
|
||||
{activeTab === 'assignment' && (
|
||||
<Card title="客服分配规则" className="max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-neutral-200 rounded-lg">
|
||||
<div className="p-4 border border-blue-100 bg-blue-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-neutral-800">当前生效:负载最低优先</div>
|
||||
<div className="text-xs text-neutral-500 mt-1">
|
||||
系统会在会话创建时,自动分配给当前「进行中会话」最少的在线客服。无在线客服时进入离线留言。
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border border-neutral-200 rounded-lg opacity-60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">轮询分配</div>
|
||||
<div className="text-xs text-neutral-400 mt-1">按在线客服顺序轮流分配会话,保证公平性</div>
|
||||
<div className="text-xs text-neutral-400 mt-1">后续版本可切换</div>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
<Switch disabled />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border border-neutral-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">负载均衡</div>
|
||||
<div className="text-xs text-neutral-400 mt-1">优先分配给当前会话数最少的客服</div>
|
||||
</div>
|
||||
<Switch />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border border-neutral-200 rounded-lg">
|
||||
<div className="p-4 border border-neutral-200 rounded-lg opacity-60">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">熟客优先</div>
|
||||
<div className="text-xs text-neutral-400 mt-1">回头客优先分配给上次接待的客服</div>
|
||||
<div className="text-xs text-neutral-400 mt-1">后续版本可切换</div>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
<Switch disabled />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => message.success('保存成功')}>保存规则</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 权限管理 */}
|
||||
{activeTab === 'permission' && (
|
||||
<Card title="权限管理" className="max-w-3xl">
|
||||
<Table
|
||||
dataSource={[
|
||||
{ key: '1', role: '租户管理员', members: 2, permissions: '全部权限', status: true },
|
||||
{ key: '2', role: '客服主管', members: 3, permissions: '会话管理、数据统计、知识库管理', status: true },
|
||||
{ key: '3', role: '一线客服', members: 8, permissions: '会话接待、客户管理、知识库查看', status: true },
|
||||
]}
|
||||
columns={[
|
||||
{ title: '角色', dataIndex: 'role', key: 'role' },
|
||||
{ title: '成员数', dataIndex: 'members', key: 'members' },
|
||||
{ title: '权限范围', dataIndex: 'permissions', key: 'permissions' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag> },
|
||||
{ title: '操作', key: 'op', render: () => <Button type="link" size="small">编辑</Button> },
|
||||
]}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
<Card title="权限管理" className="max-w-2xl">
|
||||
<p className="text-sm text-neutral-500 m-0">角色权限由系统内置(平台管理员 / 租户管理员 / 主管 / 一线客服),自定义角色将在后续版本开放。</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 自动回复 */}
|
||||
{activeTab === 'autoreply' && (
|
||||
<Card title="自动回复设置" className="max-w-2xl">
|
||||
<Form form={autoReplyForm} layout="vertical" initialValues={{ welcome: '您好!欢迎来到客服云,请问有什么可以帮您的?', offline: '当前无客服在线,请留下联系方式,我们会尽快回复您。' }}>
|
||||
<Card title="自动回复" className="max-w-2xl">
|
||||
<Form form={autoReplyForm} layout="vertical" initialValues={{ welcome: '您好!欢迎咨询,请问有什么可以帮您?', offline: '当前无客服在线,请留言并留下联系方式。' }}>
|
||||
<Form.Item name="welcome" label="欢迎语">
|
||||
<Input.TextArea rows={3} placeholder="访客打开聊天窗口后自动发送的欢迎语" maxLength={500} showCount />
|
||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="offline" label="离线留言提示">
|
||||
<Input.TextArea rows={3} placeholder="无客服在线时自动回复的提示语" maxLength={500} showCount />
|
||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={() => message.success('保存成功')}>保存设置</Button>
|
||||
<Button type="primary" onClick={() => message.info('自动回复配置接口将在后续版本接通')}>保存</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 工作时间 */}
|
||||
{activeTab === 'worktime' && (
|
||||
<Card title="工作时间设置" className="max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-700 mb-2">每周工作时段</div>
|
||||
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => (
|
||||
<div key={day} className="flex items-center gap-4 py-2 border-b border-neutral-50 last:border-0">
|
||||
<span className="text-sm text-neutral-600 w-12">{day}</span>
|
||||
<Select defaultValue={day === '周六' || day === '周日' ? undefined : '09:00-18:00'} placeholder="休息" className="w-36" size="small" allowClear options={['09:00-18:00', '08:00-17:00', '10:00-19:00', '全天'].map(v => ({ value: v, label: v }))} />
|
||||
</div>
|
||||
))}
|
||||
<Card title="工作时间" className="max-w-2xl">
|
||||
<p className="text-sm text-neutral-500 mb-4">工作时段配置将在后续版本接通,当前默认全天可接待。</p>
|
||||
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => (
|
||||
<div key={day} className="flex items-center justify-between py-2 border-b border-neutral-50">
|
||||
<span className="text-sm text-neutral-700 w-12">{day}</span>
|
||||
<Select
|
||||
defaultValue={day === '周六' || day === '周日' ? undefined : '09:00-18:00'}
|
||||
placeholder="休息"
|
||||
className="w-36"
|
||||
size="small"
|
||||
allowClear
|
||||
disabled
|
||||
options={['09:00-18:00', '08:00-17:00', '10:00-19:00', '全天'].map(v => ({ value: v, label: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-700 mb-2">非工作时间提示语</div>
|
||||
<Input placeholder="当前非工作时间,我们将在工作时段尽快回复您" />
|
||||
</div>
|
||||
<Button type="primary" onClick={() => message.success('保存成功')}>保存设置</Button>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 通知设置 */}
|
||||
{activeTab === 'notify' && (
|
||||
<Card title="通知设置" className="max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
{[ { label: '新会话提醒', desc: '有新访客发起会话时桌面通知' },
|
||||
{ label: '消息声音', desc: '收到新消息时播放提示音' },
|
||||
{ label: '离线消息通知', desc: '非工作时间收到的留言邮件通知' },
|
||||
{ label: '日报推送', desc: '每日客服数据汇总邮件' },
|
||||
{ label: '周报推送', desc: '每周客服数据汇总邮件' },
|
||||
].map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between py-2 border-b border-neutral-50 last:border-0">
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ title: '新会话提醒', desc: '有新访客进线时桌面通知' },
|
||||
{ title: '离线留言通知', desc: '访客提交离线留言时提醒' },
|
||||
{ title: '日报推送', desc: '每日服务数据摘要' },
|
||||
].map(item => (
|
||||
<div key={item.title} className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
||||
<div>
|
||||
<div className="text-sm text-neutral-700">{item.label}</div>
|
||||
<div className="text-sm font-medium text-neutral-800">{item.title}</div>
|
||||
<div className="text-xs text-neutral-400">{item.desc}</div>
|
||||
</div>
|
||||
<Switch defaultChecked={i < 3} />
|
||||
<Switch disabled defaultChecked={item.title !== '日报推送'} />
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-neutral-400">通知推送配置将在后续版本接通。</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user