优化系统设置页布局并对齐顶栏
左右栏固定 56px 顶栏,设置分类导航与各配置面板视觉对齐效果图,保留渠道与租户设置能力。
This commit is contained in:
+388
-319
@@ -1,22 +1,24 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Form, Input, Switch, Select, Button, Card, Tag, message, Spin, Empty } from 'antd'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Form, Input, Switch, Select, Button, message, Spin, Empty } from 'antd'
|
||||
import {
|
||||
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined,
|
||||
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined,
|
||||
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, SafetyCertificateOutlined,
|
||||
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
||||
} from '@ant-design/icons'
|
||||
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: '网页聊天' },
|
||||
wechat: { icon: <WechatOutlined />, label: '微信公众号' },
|
||||
app: { icon: <MobileOutlined />, label: 'APP 内嵌' },
|
||||
phone: { icon: <PhoneOutlined />, label: '电话客服' },
|
||||
email: { icon: <MailOutlined />, label: '邮件工单' },
|
||||
const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = {
|
||||
web: { icon: <GlobalOutlined />, label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' },
|
||||
wechat: { icon: <WechatOutlined />, label: '微信公众号', desc: '对接微信公众号客服消息(预留)' },
|
||||
app: { icon: <MobileOutlined />, label: 'APP 内嵌', desc: '移动端 App 内嵌客服(预留)' },
|
||||
phone: { icon: <PhoneOutlined />, label: '电话客服', desc: '电话进线与回拨(预留)' },
|
||||
email: { icon: <MailOutlined />, label: '邮件工单', desc: '邮件自动创建工单(预留)' },
|
||||
}
|
||||
|
||||
const weekDays: { key: keyof WorkHours | string; label: string }[] = [
|
||||
const weekDays: { key: string; label: string }[] = [
|
||||
{ key: 'monday', label: '周一' },
|
||||
{ key: 'tuesday', label: '周二' },
|
||||
{ key: 'wednesday', label: '周三' },
|
||||
@@ -33,6 +35,16 @@ const hourOptions = [
|
||||
{ value: '全天', label: '全天' },
|
||||
]
|
||||
|
||||
const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] = [
|
||||
{ key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: <SettingOutlined /> },
|
||||
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
||||
{ key: 'assignment', label: '客服分配规则', desc: '会话自动分配策略说明', icon: <TeamOutlined /> },
|
||||
{ key: 'permission', label: '权限管理', desc: '角色与能力说明', icon: <SafetyCertificateOutlined /> },
|
||||
{ key: 'autoreply', label: '自动回复', desc: '欢迎语与离线留言提示', icon: <MessageOutlined /> },
|
||||
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
||||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||||
]
|
||||
|
||||
const Settings = () => {
|
||||
const [activeTab, setActiveTab] = useState('basic')
|
||||
const [basicForm] = Form.useForm()
|
||||
@@ -46,15 +58,7 @@ const Settings = () => {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null)
|
||||
|
||||
const tabItems = [
|
||||
{ key: 'basic', label: '基本设置' },
|
||||
{ key: 'channels', label: '渠道管理' },
|
||||
{ key: 'assignment', label: '分配规则' },
|
||||
{ key: 'permission', label: '权限管理' },
|
||||
{ key: 'autoreply', label: '自动回复' },
|
||||
{ key: 'worktime', label: '工作时间' },
|
||||
{ key: 'notify', label: '通知设置' },
|
||||
]
|
||||
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
|
||||
|
||||
const loadChannels = async () => {
|
||||
setLoadingChannels(true)
|
||||
@@ -136,7 +140,7 @@ const Settings = () => {
|
||||
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))
|
||||
setChannels(prev => prev.map(c => (c.id === ch.id ? res.data : c)))
|
||||
message.success(enabled ? '渠道已启用' : '渠道已停用')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '更新失败')
|
||||
@@ -155,323 +159,388 @@ const Settings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const addChannelType = async (t: string) => {
|
||||
try {
|
||||
await createChannel({ type: t, name: typeMeta[t].label })
|
||||
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))
|
||||
|
||||
const panelHeaderAction = activeTab === 'channels' && missingTypes.includes('web') ? (
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={ensureWebChannel}>
|
||||
开通网页渠道
|
||||
</Button>
|
||||
) : activeTab === 'channels' && missingTypes.length > 0 ? (
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={() => addChannelType(missingTypes[0])}>
|
||||
添加新渠道
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
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>
|
||||
<div className="h-full flex min-h-0 overflow-hidden bg-neutral-50">
|
||||
{/* 左侧设置导航 — 顶栏 56px 对齐 */}
|
||||
<aside className="w-[220px] shrink-0 flex flex-col bg-white border-r border-neutral-200">
|
||||
<div
|
||||
className="shrink-0 px-4 flex flex-col justify-center border-b border-neutral-200"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="text-sm font-semibold text-neutral-900 leading-tight">系统设置</div>
|
||||
<div className="text-[11px] text-neutral-400 truncate leading-tight mt-0.5">配置与渠道接入</div>
|
||||
</div>
|
||||
{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'
|
||||
}`}
|
||||
onClick={() => setActiveTab(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
<nav className="flex-1 overflow-y-auto py-2 px-2">
|
||||
{tabItems.map(item => {
|
||||
const active = activeTab === item.key
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(item.key)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-left border-0 cursor-pointer transition-colors mb-0.5 ${
|
||||
active
|
||||
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
|
||||
: 'bg-transparent text-neutral-600 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-base leading-none ${active ? 'text-[#2563eb]' : 'text-neutral-400'}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* 右侧内容 */}
|
||||
<section className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div
|
||||
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">{currentTab.label}</h1>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{panelHeaderAction}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{activeTab === 'basic' && (
|
||||
<Card title="基本设置" className="max-w-2xl">
|
||||
{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>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto px-6 py-5">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-xs text-neutral-400 mt-0 mb-5">{currentTab.desc}</p>
|
||||
|
||||
{activeTab === 'channels' && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
</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-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>
|
||||
<Switch disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'permission' && (
|
||||
<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">
|
||||
{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">
|
||||
{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}
|
||||
{activeTab === 'basic' && (
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||||
{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>
|
||||
</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>
|
||||
<Form.Item name="language" label="语言">
|
||||
<Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item className="!mb-0">
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'notify' && (
|
||||
<Card title="通知设置" className="max-w-2xl">
|
||||
{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>
|
||||
{activeTab === 'channels' && (
|
||||
<div>
|
||||
{loadingChannels ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : channels.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-neutral-200 p-10">
|
||||
<Empty description="暂无渠道,请先开通网页渠道">
|
||||
<Button type="primary" onClick={ensureWebChannel}>开通网页渠道</Button>
|
||||
</Empty>
|
||||
</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 className="flex flex-col gap-3">
|
||||
{channels.map(ch => {
|
||||
const meta = typeMeta[ch.type] || { icon: <LinkOutlined />, label: ch.name || ch.type, desc: '' }
|
||||
const enabled = ch.status === 'enabled'
|
||||
return (
|
||||
<div
|
||||
key={ch.id}
|
||||
className="bg-white rounded-xl border border-neutral-200 shadow-sm p-4"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-[#eff6ff] text-[#2563eb] flex items-center justify-center text-lg shrink-0">
|
||||
{meta.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h3 className="text-sm font-medium text-neutral-900 m-0 truncate">
|
||||
{ch.name || meta.label}
|
||||
</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${
|
||||
enabled ? 'bg-[#f0fdf4] text-[#16a34a]' : 'bg-neutral-100 text-neutral-500'
|
||||
}`}
|
||||
>
|
||||
{enabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
size="small"
|
||||
loading={togglingId === ch.id}
|
||||
onChange={v => toggleChannel(ch, v)}
|
||||
/>
|
||||
</div>
|
||||
{meta.desc && (
|
||||
<p className="text-xs text-neutral-400 m-0 mb-3">{meta.desc}</p>
|
||||
)}
|
||||
{ch.channel_key && (
|
||||
<div className="flex items-center gap-2 text-xs mb-2">
|
||||
<span className="text-neutral-400">渠道 ID</span>
|
||||
<code className="bg-neutral-100 text-neutral-600 px-2 py-0.5 rounded">
|
||||
{ch.channel_key}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{ch.type === 'web' && ch.script_code && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-neutral-400 mb-1">接入代码</div>
|
||||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-2.5 flex items-start justify-between gap-2">
|
||||
<code className="text-[11px] text-neutral-600 break-all leading-relaxed">
|
||||
{ch.script_code.replace(
|
||||
'src="/widget.js"',
|
||||
`src="${window.location.origin}/widget.js"`,
|
||||
)}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||||
title="复制"
|
||||
onClick={() => copyScript(ch.script_code)}
|
||||
>
|
||||
<CopyOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ch.type !== 'web' && (
|
||||
<div className="text-xs text-neutral-400 mt-1">
|
||||
本期仅网页渠道可完整接入,其他类型预留开关。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
|
||||
)}
|
||||
|
||||
{missingTypes.length > 0 && channels.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<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={() => addChannelType(t)}>
|
||||
{typeMeta[t].label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'assignment' && (
|
||||
<div className="space-y-3">
|
||||
<div className="bg-white rounded-xl border border-[#dbeafe] shadow-sm p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-[#eff6ff] text-[#2563eb] flex items-center justify-center shrink-0">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">日报推送</div>
|
||||
<div className="text-xs text-neutral-400">每日服务数据摘要</div>
|
||||
<div className="text-sm font-medium text-neutral-800">当前生效:负载最低优先 + 工作时间</div>
|
||||
<div className="text-xs text-neutral-500 mt-1.5 leading-relaxed">
|
||||
工作时间内,新会话自动分配给进行中会话最少的在线客服;非工作时间或无客服在线时,访客可提交离线留言。
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item name="notify_daily_report" valuePropName="checked" className="!mb-0">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存通知设置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="bg-white rounded-xl border border-neutral-200 p-5 opacity-70">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'permission' && (
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||||
<div className="text-sm text-neutral-600 leading-relaxed space-y-3">
|
||||
<p className="m-0">角色权限由系统内置,自定义角色将在后续版本开放:</p>
|
||||
<ul className="m-0 pl-5 space-y-2 text-sm text-neutral-600">
|
||||
<li><span className="font-medium text-neutral-800">租户管理员</span> — 全部配置、知识库、统计、坐席管理</li>
|
||||
<li><span className="font-medium text-neutral-800">主管</span> — 会话监管、客户与知识库管理、数据统计</li>
|
||||
<li><span className="font-medium text-neutral-800">一线客服</span> — 工作台接待、查看客户与知识库</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'autoreply' && (
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||||
{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 className="!mb-0">
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'worktime' && (
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||||
{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>
|
||||
<div className="rounded-lg border border-neutral-100 overflow-hidden mb-4">
|
||||
{weekDays.map((day, i) => (
|
||||
<div
|
||||
key={day.key}
|
||||
className={`flex items-center justify-between px-4 py-2.5 ${
|
||||
i < weekDays.length - 1 ? '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-44"
|
||||
size="small"
|
||||
allowClear
|
||||
options={hourOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Form.Item name="worktime_prompt" label="非工作时间提示" rules={[{ max: 500 }]}>
|
||||
<Input placeholder="当前为非工作时间,我们会在工作时段尽快回复您" maxLength={500} />
|
||||
</Form.Item>
|
||||
<Form.Item className="!mb-0">
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存工作时间</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notify' && (
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||||
{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-5">
|
||||
{[
|
||||
{ name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒(推送通道后续接入)' },
|
||||
{ name: 'notify_offline_leave', title: '离线留言通知', desc: '访客提交离线留言时提醒' },
|
||||
{ name: 'notify_daily_report', title: '日报推送', desc: '每日服务数据摘要' },
|
||||
].map(item => (
|
||||
<div
|
||||
key={item.name}
|
||||
className="flex items-center justify-between p-3.5 border border-neutral-100 rounded-lg"
|
||||
>
|
||||
<div className="min-w-0 pr-3">
|
||||
<div className="text-sm font-medium text-neutral-800">{item.title}</div>
|
||||
<div className="text-xs text-neutral-400 mt-0.5">{item.desc}</div>
|
||||
</div>
|
||||
<Form.Item name={item.name} valuePropName="checked" className="!mb-0">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Form.Item className="!mb-0">
|
||||
<Button type="primary" htmlType="submit" loading={saving}>保存通知设置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user