实现租户系统设置落库:欢迎语、工作时间与通知开关
- 新增 TenantSetting 模型与 /api/settings 读写接口 - 设置页接通基本资料、自动回复、工作时间、通知偏好 - Widget 初始化使用欢迎语/离线提示,非工作时间不自动分配 - 补充设置持久化与 Widget 提示集成测试
This commit is contained in:
@@ -3,7 +3,10 @@ import { Form, Input, Switch, Select, Button, Card, Tag, message, Spin, Empty }
|
||||
import {
|
||||
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { createChannel, getChannels, updateChannel, type Channel } from '@/services/api'
|
||||
import {
|
||||
createChannel, getChannels, getTenantSettings, updateChannel, updateTenantSettings,
|
||||
type Channel, type TenantSettings, type WorkHours,
|
||||
} from '@/services/api'
|
||||
|
||||
const typeMeta: Record<string, { icon: React.ReactNode; label: string }> = {
|
||||
web: { icon: <LinkOutlined />, label: '网页聊天' },
|
||||
@@ -13,12 +16,34 @@ const typeMeta: Record<string, { icon: React.ReactNode; label: string }> = {
|
||||
email: { icon: <MailOutlined />, label: '邮件工单' },
|
||||
}
|
||||
|
||||
const weekDays: { key: keyof WorkHours | string; label: string }[] = [
|
||||
{ key: 'monday', label: '周一' },
|
||||
{ key: 'tuesday', label: '周二' },
|
||||
{ key: 'wednesday', label: '周三' },
|
||||
{ key: 'thursday', label: '周四' },
|
||||
{ key: 'friday', label: '周五' },
|
||||
{ key: 'saturday', label: '周六' },
|
||||
{ key: 'sunday', label: '周日' },
|
||||
]
|
||||
|
||||
const hourOptions = [
|
||||
{ value: '09:00-18:00', label: '09:00-18:00' },
|
||||
{ value: '08:00-17:00', label: '08:00-17:00' },
|
||||
{ value: '10:00-19:00', label: '10:00-19:00' },
|
||||
{ value: '全天', label: '全天' },
|
||||
]
|
||||
|
||||
const Settings = () => {
|
||||
const [activeTab, setActiveTab] = useState('channels')
|
||||
const [activeTab, setActiveTab] = useState('basic')
|
||||
const [basicForm] = Form.useForm()
|
||||
const [autoReplyForm] = Form.useForm()
|
||||
const [workForm] = Form.useForm()
|
||||
const [notifyForm] = Form.useForm()
|
||||
const [channels, setChannels] = useState<Channel[]>([])
|
||||
const [settings, setSettings] = useState<TenantSettings | null>(null)
|
||||
const [loadingChannels, setLoadingChannels] = useState(false)
|
||||
const [loadingSettings, setLoadingSettings] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
const tabItems = [
|
||||
@@ -44,10 +69,56 @@ const Settings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoadingSettings(true)
|
||||
try {
|
||||
const res = await getTenantSettings()
|
||||
const data = res.data
|
||||
setSettings(data)
|
||||
basicForm.setFieldsValue({
|
||||
display_name: data.display_name,
|
||||
agent_nickname: data.agent_nickname,
|
||||
timezone: data.timezone || 'Asia/Shanghai',
|
||||
language: 'zh-CN',
|
||||
})
|
||||
autoReplyForm.setFieldsValue({
|
||||
welcome_message: data.welcome_message,
|
||||
offline_prompt: data.offline_prompt,
|
||||
})
|
||||
workForm.setFieldsValue({
|
||||
worktime_prompt: data.worktime_prompt,
|
||||
...Object.fromEntries(weekDays.map(d => [d.key, data.work_hours?.[d.key] || undefined])),
|
||||
})
|
||||
notifyForm.setFieldsValue({
|
||||
notify_new_session: data.notify_new_session,
|
||||
notify_offline_leave: data.notify_offline_leave,
|
||||
notify_daily_report: data.notify_daily_report,
|
||||
})
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载设置失败')
|
||||
} finally {
|
||||
setLoadingSettings(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'channels') loadChannels()
|
||||
if (['basic', 'autoreply', 'worktime', 'notify'].includes(activeTab)) loadSettings()
|
||||
}, [activeTab])
|
||||
|
||||
const savePartial = async (payload: Parameters<typeof updateTenantSettings>[0], okText = '已保存') => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await updateTenantSettings(payload)
|
||||
setSettings(res.data)
|
||||
message.success(okText)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyScript = async (script: string) => {
|
||||
try {
|
||||
const origin = window.location.origin
|
||||
@@ -110,23 +181,40 @@ const Settings = () => {
|
||||
<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' }}>
|
||||
<Form.Item name="name" label="租户名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="公司名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="nickname" label="客服昵称">
|
||||
<Input placeholder="客服在访客端显示的昵称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="timezone" label="时区">
|
||||
<Select options={[{ value: 'Asia/Shanghai', label: '东八区 (UTC+8)' }, { value: 'Asia/Tokyo', label: '东京 (UTC+9)' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label="语言">
|
||||
<Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={() => message.info('租户基本资料接口将在后续版本接通')}>保存设置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{loadingSettings && !settings ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Form
|
||||
form={basicForm}
|
||||
layout="vertical"
|
||||
onFinish={values => savePartial({
|
||||
display_name: values.display_name,
|
||||
agent_nickname: values.agent_nickname,
|
||||
timezone: values.timezone,
|
||||
})}
|
||||
>
|
||||
<Form.Item name="display_name" label="租户名称" rules={[{ required: true }, { min: 1, max: 100 }]}>
|
||||
<Input placeholder="公司名称 / 访客端展示名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agent_nickname" label="客服默认昵称">
|
||||
<Input placeholder="客服在访客端显示的昵称" maxLength={50} />
|
||||
</Form.Item>
|
||||
<Form.Item name="timezone" label="时区">
|
||||
<Select options={[
|
||||
{ value: 'Asia/Shanghai', label: '东八区 (UTC+8)' },
|
||||
{ value: 'Asia/Tokyo', label: '东京 (UTC+9)' },
|
||||
{ value: 'UTC', label: 'UTC' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="language" label="语言">
|
||||
<Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -188,18 +276,11 @@ const Settings = () => {
|
||||
</div>
|
||||
)}
|
||||
{ch.type !== 'web' && (
|
||||
<div className="text-xs text-neutral-400">
|
||||
本期仅网页渠道可完整接入,其他渠道预留开关。
|
||||
</div>
|
||||
<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)}
|
||||
/>
|
||||
<Switch checked={enabled} size="small" loading={togglingId === ch.id} onChange={v => toggleChannel(ch, v)} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -240,9 +321,9 @@ const Settings = () => {
|
||||
<Card title="客服分配规则" className="max-w-2xl">
|
||||
<div className="space-y-4">
|
||||
<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-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">
|
||||
@@ -254,79 +335,140 @@ const Settings = () => {
|
||||
<Switch disabled />
|
||||
</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>
|
||||
<Switch disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'permission' && (
|
||||
<Card title="权限管理" className="max-w-2xl">
|
||||
<p className="text-sm text-neutral-500 m-0">角色权限由系统内置(平台管理员 / 租户管理员 / 主管 / 一线客服),自定义角色将在后续版本开放。</p>
|
||||
<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: '当前无客服在线,请留言并留下联系方式。' }}>
|
||||
<Form.Item name="welcome" label="欢迎语">
|
||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="offline" label="离线留言提示">
|
||||
<Input.TextArea rows={3} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={() => message.info('自动回复配置接口将在后续版本接通')}>保存</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{loadingSettings && !settings ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Form
|
||||
form={autoReplyForm}
|
||||
layout="vertical"
|
||||
onFinish={values => savePartial({
|
||||
welcome_message: values.welcome_message,
|
||||
offline_prompt: values.offline_prompt,
|
||||
}, '自动回复已保存')}
|
||||
>
|
||||
<Form.Item name="welcome_message" label="欢迎语" rules={[{ max: 500 }]}>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount placeholder="访客打开窗口时展示" />
|
||||
</Form.Item>
|
||||
<Form.Item name="offline_prompt" label="离线留言提示" rules={[{ max: 500 }]}>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount placeholder="无客服在线时展示" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'worktime' && (
|
||||
<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>
|
||||
))}
|
||||
{loadingSettings && !settings ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Form
|
||||
form={workForm}
|
||||
layout="vertical"
|
||||
onFinish={values => {
|
||||
const work_hours: WorkHours = {}
|
||||
weekDays.forEach(d => {
|
||||
work_hours[d.key] = values[d.key] || ''
|
||||
})
|
||||
return savePartial({
|
||||
work_hours,
|
||||
worktime_prompt: values.worktime_prompt,
|
||||
}, '工作时间已保存')
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-neutral-500 mb-4">
|
||||
非工作时间访客将看到下方提示并可留言;清空某天时段表示休息。
|
||||
</p>
|
||||
{weekDays.map(day => (
|
||||
<div key={day.key} className="flex items-center justify-between py-2 border-b border-neutral-50">
|
||||
<span className="text-sm text-neutral-700 w-12">{day.label}</span>
|
||||
<Form.Item name={day.key} className="!mb-0">
|
||||
<Select
|
||||
placeholder="休息"
|
||||
className="w-40"
|
||||
size="small"
|
||||
allowClear
|
||||
options={hourOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
<Form.Item name="worktime_prompt" label="非工作时间提示" className="mt-4" rules={[{ max: 500 }]}>
|
||||
<Input placeholder="当前为非工作时间,我们会在工作时段尽快回复您" maxLength={500} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存工作时间</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'notify' && (
|
||||
<Card title="通知设置" className="max-w-2xl">
|
||||
<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 font-medium text-neutral-800">{item.title}</div>
|
||||
<div className="text-xs text-neutral-400">{item.desc}</div>
|
||||
{loadingSettings && !settings ? (
|
||||
<div className="py-10 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Form
|
||||
form={notifyForm}
|
||||
layout="vertical"
|
||||
onFinish={values => savePartial({
|
||||
notify_new_session: values.notify_new_session,
|
||||
notify_offline_leave: values.notify_offline_leave,
|
||||
notify_daily_report: values.notify_daily_report,
|
||||
}, '通知设置已保存')}
|
||||
>
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">新会话提醒</div>
|
||||
<div className="text-xs text-neutral-400">有新访客进线时提醒(偏好开关,推送通道后续接入)</div>
|
||||
</div>
|
||||
<Form.Item name="notify_new_session" valuePropName="checked" className="!mb-0">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">离线留言通知</div>
|
||||
<div className="text-xs text-neutral-400">访客提交离线留言时提醒</div>
|
||||
</div>
|
||||
<Form.Item name="notify_offline_leave" valuePropName="checked" className="!mb-0">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">日报推送</div>
|
||||
<div className="text-xs text-neutral-400">每日服务数据摘要</div>
|
||||
</div>
|
||||
<Form.Item name="notify_daily_report" valuePropName="checked" className="!mb-0">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Switch disabled defaultChecked={item.title !== '日报推送'} />
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-neutral-400">通知推送配置将在后续版本接通。</div>
|
||||
</div>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存通知设置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -142,6 +142,25 @@ export const getChannels = () => get<Channel[]>('/channels')
|
||||
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
|
||||
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/channels/${id}`, data)
|
||||
|
||||
// Tenant settings
|
||||
export type WorkHours = Record<string, string>
|
||||
export interface TenantSettings {
|
||||
tenant_id: number
|
||||
display_name: string
|
||||
agent_nickname: string
|
||||
timezone: string
|
||||
welcome_message: string
|
||||
offline_prompt: string
|
||||
work_hours: WorkHours
|
||||
worktime_prompt: string
|
||||
notify_new_session: boolean
|
||||
notify_offline_leave: boolean
|
||||
notify_daily_report: boolean
|
||||
}
|
||||
export const getTenantSettings = () => get<TenantSettings>('/settings')
|
||||
export const updateTenantSettings = (data: Partial<TenantSettings> & { work_hours?: WorkHours }) =>
|
||||
put<TenantSettings>('/settings', data)
|
||||
|
||||
// Statistics
|
||||
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
|
||||
export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`)
|
||||
|
||||
@@ -56,6 +56,8 @@ const VisitorChat = ({
|
||||
const [sendError, setSendError] = useState('')
|
||||
const [agentsOnline, setAgentsOnline] = useState(true)
|
||||
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
|
||||
const [welcomeMessage, setWelcomeMessage] = useState('您好!欢迎咨询,请问有什么可以帮您?')
|
||||
const [displayName, setDisplayName] = useState('在线客服')
|
||||
const [agentName, setAgentName] = useState('')
|
||||
const [leaveName, setLeaveName] = useState('')
|
||||
const [leavePhone, setLeavePhone] = useState('')
|
||||
@@ -112,7 +114,10 @@ const VisitorChat = ({
|
||||
setVisitorToken(token)
|
||||
setAgentsOnline(Boolean(data.agents_online))
|
||||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
||||
if (data.welcome_message) setWelcomeMessage(data.welcome_message)
|
||||
if (data.display_name) setDisplayName(data.display_name)
|
||||
if (data.agent_name) setAgentName(data.agent_name)
|
||||
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
||||
if (data.session_status === 'ended') setSessionEnded(true)
|
||||
localStorage.setItem(storageKey, String(sid))
|
||||
localStorage.setItem(tokenKey, token)
|
||||
@@ -365,7 +370,7 @@ const VisitorChat = ({
|
||||
<CustomerServiceOutlined className="text-lg text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">在线客服</h1>
|
||||
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">{displayName || '在线客服'}</h1>
|
||||
<p className="m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
@@ -375,7 +380,7 @@ const VisitorChat = ({
|
||||
? '会话已结束'
|
||||
: agentsOnline
|
||||
? (agentName ? `${agentName} 为您服务` : '正在为您服务')
|
||||
: '客服离线 · 可留言'}
|
||||
: '暂不可即时接待 · 可留言'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
@@ -405,9 +410,7 @@ const VisitorChat = ({
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="px-4 py-2 rounded-xl bg-white border border-neutral-200 max-w-[85%] text-center">
|
||||
<p className="m-0 text-[13px] text-neutral-600 leading-normal">
|
||||
{agentsOnline
|
||||
? '您好!欢迎咨询,请问有什么可以帮您?'
|
||||
: offlinePrompt}
|
||||
{agentsOnline ? welcomeMessage : offlinePrompt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user