欢迎语支持多段配置与图片

租户可配置有序文本/图片欢迎语;设置页支持增删排序与上传,Widget 空会话按序展示;兼容旧 welcome_message 字段。
This commit is contained in:
yml2213
2026-07-17 21:47:21 +08:00
parent 7319799ec4
commit 54d23bb595
8 changed files with 504 additions and 51 deletions
+207 -13
View File
@@ -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>
+12 -2
View File
@@ -354,12 +354,21 @@ export const deleteStaff = (id: number) => del(`/staff/${id}`)
export type WorkHours = Record<string, string>
export type AssignStrategy = 'least_load' | 'round_robin'
/** 欢迎语段落:文本或图片 URL */
export type WelcomeSegment = {
type: 'text' | 'image'
content: string
}
export interface TenantSettings {
tenant_id: number
display_name: string
agent_nickname: string
timezone: string
/** 兼容字段:首段文本欢迎语 */
welcome_message: string
/** 多段欢迎语(含图片) */
welcome_messages?: WelcomeSegment[]
offline_prompt: string
work_hours: WorkHours
worktime_prompt: string
@@ -372,8 +381,9 @@ export interface TenantSettings {
max_active_per_agent?: number
}
export const getTenantSettings = () => get<TenantSettings>('/settings')
export const updateTenantSettings = (data: Partial<TenantSettings> & { work_hours?: WorkHours }) =>
put<TenantSettings>('/settings', data)
export const updateTenantSettings = (
data: Partial<TenantSettings> & { work_hours?: WorkHours; welcome_messages?: WelcomeSegment[] },
) => put<TenantSettings>('/settings', data)
// Statistics
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
+58 -8
View File
@@ -18,6 +18,34 @@ interface Message {
const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
type WelcomeSegment = { type: 'text' | 'image'; content: string }
const defaultWelcomeSegments: WelcomeSegment[] = [
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
]
function parseWelcomeSegments(data: {
welcome_messages?: unknown
welcome_message?: string
}): WelcomeSegment[] {
if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) {
const segs: WelcomeSegment[] = []
for (const raw of data.welcome_messages) {
if (!raw || typeof raw !== 'object') continue
const item = raw as { type?: string; content?: string }
const content = typeof item.content === 'string' ? item.content.trim() : ''
if (!content) continue
if (item.type === 'image') segs.push({ type: 'image', content })
else segs.push({ type: 'text', content })
}
if (segs.length > 0) return segs
}
if (typeof data.welcome_message === 'string' && data.welcome_message.trim()) {
return [{ type: 'text', content: data.welcome_message.trim() }]
}
return defaultWelcomeSegments
}
type LayoutMode = 'floating' | 'fill'
interface VisitorChatProps {
@@ -76,7 +104,7 @@ const VisitorChat = ({
const [sendError, setSendError] = useState('')
const [agentsOnline, setAgentsOnline] = useState(true)
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
const [welcomeMessage, setWelcomeMessage] = useState('您好!欢迎咨询,请问有什么可以帮您?')
const [welcomeSegments, setWelcomeSegments] = useState<WelcomeSegment[]>(defaultWelcomeSegments)
const [displayName, setDisplayName] = useState('在线客服')
const [agentName, setAgentName] = useState('')
const [leaveName, setLeaveName] = useState('')
@@ -291,7 +319,7 @@ const VisitorChat = ({
visitorTokenRef.current = token
setAgentsOnline(Boolean(data.agents_online))
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
if (data.welcome_message) setWelcomeMessage(data.welcome_message)
setWelcomeSegments(parseWelcomeSegments(data))
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)
@@ -786,12 +814,34 @@ const VisitorChat = ({
<section className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-neutral-50 no-scrollbar">
{showWelcome && (
<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 ? welcomeMessage : offlinePrompt}
</p>
</div>
<div className="flex flex-col items-start gap-2 max-w-[90%]">
{agentsOnline ? (
welcomeSegments.map((seg, i) =>
seg.type === 'image' ? (
<div
key={`w-${i}`}
className="rounded-xl bg-white border border-neutral-200 p-1.5"
>
<ChatImage
src={seg.content}
alt="欢迎图"
className="max-w-[220px] max-h-[220px]"
/>
</div>
) : (
<div
key={`w-${i}`}
className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-700 leading-normal whitespace-pre-wrap"
>
{seg.content}
</div>
),
)
) : (
<div className="px-4 py-2.5 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-600 leading-normal">
{offlinePrompt}
</div>
)}
</div>
)}