欢迎语支持多段配置与图片
租户可配置有序文本/图片欢迎语;设置页支持增删排序与上传,Widget 空会话按序展示;兼容旧 welcome_message 字段。
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm, TimePicker } from 'antd'
|
||||
import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm, TimePicker, Upload } from 'antd'
|
||||
import {
|
||||
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined,
|
||||
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
|
||||
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
||||
EditOutlined, StopOutlined, CheckCircleOutlined,
|
||||
EditOutlined, StopOutlined, CheckCircleOutlined, DeleteOutlined,
|
||||
ArrowUpOutlined, ArrowDownOutlined, PictureOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import {
|
||||
createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings,
|
||||
updateChannel, updateStaff, updateTenantSettings,
|
||||
type Channel, type StaffUser, type TenantSettings, type WorkHours,
|
||||
updateChannel, updateStaff, updateTenantSettings, uploadImage,
|
||||
type Channel, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
@@ -80,6 +81,21 @@ function defaultCustomRange(): [Dayjs, Dayjs] {
|
||||
return [dayjs('09:00', 'HH:mm'), dayjs('18:00', 'HH:mm')]
|
||||
}
|
||||
|
||||
const MAX_WELCOME_SEGMENTS = 10
|
||||
|
||||
function normalizeWelcomeFromSettings(data: TenantSettings): WelcomeSegment[] {
|
||||
if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) {
|
||||
return data.welcome_messages.map(s => ({
|
||||
type: s.type === 'image' ? 'image' : 'text',
|
||||
content: s.content || '',
|
||||
}))
|
||||
}
|
||||
if (data.welcome_message?.trim()) {
|
||||
return [{ type: 'text', content: data.welcome_message.trim() }]
|
||||
}
|
||||
return [{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }]
|
||||
}
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
supervisor: '主管',
|
||||
@@ -98,7 +114,7 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[]
|
||||
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
||||
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
||||
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
|
||||
{ key: 'autoreply', label: '自动回复', desc: '欢迎语与离线留言提示', icon: <MessageOutlined /> },
|
||||
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
|
||||
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
||||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||||
]
|
||||
@@ -129,6 +145,10 @@ const Settings = () => {
|
||||
const [staffModalOpen, setStaffModalOpen] = useState(false)
|
||||
const [editingStaff, setEditingStaff] = useState<StaffUser | null>(null)
|
||||
const [savingStaff, setSavingStaff] = useState(false)
|
||||
const [welcomeSegments, setWelcomeSegments] = useState<WelcomeSegment[]>([
|
||||
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
|
||||
])
|
||||
const [uploadingWelcomeIdx, setUploadingWelcomeIdx] = useState<number | null>(null)
|
||||
|
||||
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
|
||||
|
||||
@@ -173,9 +193,9 @@ const Settings = () => {
|
||||
language: 'zh-CN',
|
||||
})
|
||||
autoReplyForm.setFieldsValue({
|
||||
welcome_message: data.welcome_message,
|
||||
offline_prompt: data.offline_prompt,
|
||||
})
|
||||
setWelcomeSegments(normalizeWelcomeFromSettings(data))
|
||||
{
|
||||
const workFields: Record<string, unknown> = {
|
||||
worktime_prompt: data.worktime_prompt,
|
||||
@@ -284,6 +304,9 @@ const Settings = () => {
|
||||
try {
|
||||
const res = await updateTenantSettings(payload)
|
||||
setSettings(res.data)
|
||||
if (payload.welcome_messages || res.data?.welcome_messages) {
|
||||
setWelcomeSegments(normalizeWelcomeFromSettings(res.data))
|
||||
}
|
||||
message.success(okText)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
@@ -764,14 +787,185 @@ const Settings = () => {
|
||||
<Form
|
||||
form={autoReplyForm}
|
||||
layout="vertical"
|
||||
onFinish={values => savePartial({
|
||||
welcome_message: values.welcome_message,
|
||||
offline_prompt: values.offline_prompt,
|
||||
}, '自动回复已保存')}
|
||||
onFinish={async values => {
|
||||
if (welcomeSegments.length === 0) {
|
||||
message.error('至少保留一段欢迎语')
|
||||
return
|
||||
}
|
||||
for (let i = 0; i < welcomeSegments.length; i++) {
|
||||
const seg = welcomeSegments[i]
|
||||
if (seg.type === 'text' && !seg.content.trim()) {
|
||||
message.error(`第 ${i + 1} 段文本不能为空`)
|
||||
return
|
||||
}
|
||||
if (seg.type === 'image' && !seg.content.trim()) {
|
||||
message.error(`第 ${i + 1} 段请上传图片`)
|
||||
return
|
||||
}
|
||||
}
|
||||
await savePartial({
|
||||
welcome_messages: welcomeSegments.map(s => ({
|
||||
type: s.type,
|
||||
content: s.content.trim(),
|
||||
})),
|
||||
offline_prompt: values.offline_prompt,
|
||||
}, '自动回复已保存')
|
||||
}}
|
||||
>
|
||||
<Form.Item name="welcome_message" label="欢迎语" rules={[{ max: 500 }]}>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount placeholder="访客打开窗口时展示" />
|
||||
</Form.Item>
|
||||
<div className="mb-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-800">欢迎语(多段)</div>
|
||||
<p className="text-xs text-neutral-500 m-0 mt-0.5">
|
||||
访客打开窗口且尚无消息时按顺序展示;可混排文本与图片,最多 {MAX_WELCOME_SEGMENTS} 段。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{welcomeSegments.map((seg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-lg border border-neutral-200 bg-neutral-50/60 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-xs font-medium text-neutral-500">
|
||||
第 {idx + 1} 段 · {seg.type === 'image' ? '图片' : '文本'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={idx === 0}
|
||||
onClick={() => {
|
||||
setWelcomeSegments(prev => {
|
||||
if (idx <= 0) return prev
|
||||
const next = [...prev]
|
||||
;[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={idx >= welcomeSegments.length - 1}
|
||||
onClick={() => {
|
||||
setWelcomeSegments(prev => {
|
||||
if (idx >= prev.length - 1) return prev
|
||||
const next = [...prev]
|
||||
;[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]]
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={welcomeSegments.length <= 1}
|
||||
onClick={() => {
|
||||
setWelcomeSegments(prev => prev.filter((_, i) => i !== idx))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{seg.type === 'text' ? (
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
showCount
|
||||
value={seg.content}
|
||||
placeholder="访客打开窗口时展示的文案"
|
||||
onChange={e => {
|
||||
const v = e.target.value
|
||||
setWelcomeSegments(prev =>
|
||||
prev.map((s, i) => (i === idx ? { ...s, content: v } : s)),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
{seg.content ? (
|
||||
<img
|
||||
src={seg.content}
|
||||
alt={`欢迎图 ${idx + 1}`}
|
||||
className="max-h-28 max-w-[200px] rounded-lg border border-neutral-200 object-contain bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-28 h-20 rounded-lg border border-dashed border-neutral-300 flex items-center justify-center text-neutral-400 text-xs bg-white">
|
||||
未上传
|
||||
</div>
|
||||
)}
|
||||
<Upload
|
||||
accept="image/*"
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
setUploadingWelcomeIdx(idx)
|
||||
try {
|
||||
const res = await uploadImage(file as File)
|
||||
const url = res.data?.url
|
||||
if (!url) throw new Error('上传未返回地址')
|
||||
setWelcomeSegments(prev =>
|
||||
prev.map((s, i) => (i === idx ? { ...s, content: url } : s)),
|
||||
)
|
||||
onSuccess?.(res)
|
||||
message.success('图片已上传')
|
||||
} catch (e) {
|
||||
const err = e instanceof Error ? e : new Error('上传失败')
|
||||
onError?.(err)
|
||||
message.error(err.message)
|
||||
} finally {
|
||||
setUploadingWelcomeIdx(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<PictureOutlined />}
|
||||
loading={uploadingWelcomeIdx === idx}
|
||||
size="small"
|
||||
>
|
||||
{seg.content ? '重新上传' : '上传图片'}
|
||||
</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={welcomeSegments.length >= MAX_WELCOME_SEGMENTS}
|
||||
onClick={() =>
|
||||
setWelcomeSegments(prev =>
|
||||
prev.length >= MAX_WELCOME_SEGMENTS
|
||||
? prev
|
||||
: [...prev, { type: 'text', content: '' }],
|
||||
)
|
||||
}
|
||||
>
|
||||
添加文本
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PictureOutlined />}
|
||||
disabled={welcomeSegments.length >= MAX_WELCOME_SEGMENTS}
|
||||
onClick={() =>
|
||||
setWelcomeSegments(prev =>
|
||||
prev.length >= MAX_WELCOME_SEGMENTS
|
||||
? prev
|
||||
: [...prev, { type: 'image', content: '' }],
|
||||
)
|
||||
}
|
||||
>
|
||||
添加图片
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item name="offline_prompt" label="离线留言提示" rules={[{ max: 500 }]}>
|
||||
<Input.TextArea rows={3} maxLength={500} showCount placeholder="无客服在线时展示" />
|
||||
</Form.Item>
|
||||
|
||||
Reference in New Issue
Block a user