diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index ee18f63..c225deb 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -748,9 +748,9 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { t.Fatalf("创建渠道失败: %v", err) } - // 保存欢迎语与离线提示 + // 保存多段欢迎语与离线提示 saveRec := httptest.NewRecorder() - saveBody := `{"welcome_message":"定制欢迎语ABC","offline_prompt":"定制离线提示XYZ","work_hours":{"monday":"全天","tuesday":"全天","wednesday":"全天","thursday":"全天","friday":"全天","saturday":"全天","sunday":"全天"}}` + saveBody := `{"welcome_messages":[{"type":"text","content":"定制欢迎语ABC"},{"type":"text","content":"第二段欢迎"}],"offline_prompt":"定制离线提示XYZ","work_hours":{"monday":"全天","tuesday":"全天","wednesday":"全天","thursday":"全天","friday":"全天","saturday":"全天","sunday":"全天"}}` router.ServeHTTP(saveRec, bearerRequest(t, http.MethodPut, "/api/settings", []byte(saveBody), admin)) if saveRec.Code != http.StatusOK { t.Fatalf("保存设置失败: %s", saveRec.Body.String()) @@ -763,8 +763,12 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { } var getResp struct { Data struct { - WelcomeMessage string `json:"welcome_message"` - OfflinePrompt string `json:"offline_prompt"` + WelcomeMessage string `json:"welcome_message"` + WelcomeMessages []struct { + Type string `json:"type"` + Content string `json:"content"` + } `json:"welcome_messages"` + OfflinePrompt string `json:"offline_prompt"` } `json:"data"` } if err := json.Unmarshal(getRec.Body.Bytes(), &getResp); err != nil { @@ -773,6 +777,9 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { if getResp.Data.WelcomeMessage != "定制欢迎语ABC" || getResp.Data.OfflinePrompt != "定制离线提示XYZ" { t.Fatalf("设置未持久化: %+v", getResp.Data) } + if len(getResp.Data.WelcomeMessages) != 2 || getResp.Data.WelcomeMessages[1].Content != "第二段欢迎" { + t.Fatalf("多段欢迎语未持久化: %+v", getResp.Data.WelcomeMessages) + } // 无在线客服时 init 应返回定制离线提示 initRec := httptest.NewRecorder() @@ -784,9 +791,13 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { } var initResp struct { Data struct { - WelcomeMessage string `json:"welcome_message"` - OfflinePrompt string `json:"offline_prompt"` - AgentsOnline bool `json:"agents_online"` + WelcomeMessage string `json:"welcome_message"` + WelcomeMessages []struct { + Type string `json:"type"` + Content string `json:"content"` + } `json:"welcome_messages"` + OfflinePrompt string `json:"offline_prompt"` + AgentsOnline bool `json:"agents_online"` } `json:"data"` } if err := json.Unmarshal(initRec.Body.Bytes(), &initResp); err != nil { @@ -798,4 +809,7 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) { if initResp.Data.WelcomeMessage != "定制欢迎语ABC" || initResp.Data.OfflinePrompt != "定制离线提示XYZ" { t.Fatalf("widget 未使用租户设置: %+v", initResp.Data) } + if len(initResp.Data.WelcomeMessages) != 2 { + t.Fatalf("widget 未返回多段欢迎语: %+v", initResp.Data.WelcomeMessages) + } } diff --git a/server/internal/handler/settings.go b/server/internal/handler/settings.go index d61731a..03e2ba9 100644 --- a/server/internal/handler/settings.go +++ b/server/internal/handler/settings.go @@ -6,6 +6,7 @@ import ( "net/http" "strings" "time" + "unicode/utf8" "github.com/gin-gonic/gin" "kefu-cloud/server/internal/middleware" @@ -22,10 +23,99 @@ const ( defaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。" defaultAgentNickname = "在线客服" defaultTimezone = "Asia/Shanghai" + + maxWelcomeSegments = 10 + maxWelcomeTextLen = 500 ) +// WelcomeSegment 单段欢迎语:文本或图片(content 为正文或对象存储 URL)。 +type WelcomeSegment struct { + Type string `json:"type"` // text | image + Content string `json:"content"` +} + var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} +func defaultWelcomeSegments() []WelcomeSegment { + return []WelcomeSegment{{Type: "text", Content: defaultWelcomeMessage}} +} + +func defaultWelcomeMessagesJSON() string { + b, _ := json.Marshal(defaultWelcomeSegments()) + return string(b) +} + +func firstWelcomeText(segs []WelcomeSegment) string { + for _, s := range segs { + if s.Type == "text" && strings.TrimSpace(s.Content) != "" { + return s.Content + } + } + return defaultWelcomeMessage +} + +// resolveWelcomeSegments 解析多段欢迎语;兼容仅有旧 welcome_message 的数据。 +func resolveWelcomeSegments(setting *model.TenantSetting) []WelcomeSegment { + if setting == nil { + return defaultWelcomeSegments() + } + raw := strings.TrimSpace(setting.WelcomeMessagesJSON) + if raw != "" { + var segs []WelcomeSegment + if err := json.Unmarshal([]byte(raw), &segs); err == nil { + out := make([]WelcomeSegment, 0, len(segs)) + for _, s := range segs { + t := strings.TrimSpace(s.Type) + c := strings.TrimSpace(s.Content) + if (t == "text" || t == "image") && c != "" { + out = append(out, WelcomeSegment{Type: t, Content: c}) + } + } + if len(out) > 0 { + return out + } + } + } + if msg := strings.TrimSpace(setting.WelcomeMessage); msg != "" { + return []WelcomeSegment{{Type: "text", Content: msg}} + } + return defaultWelcomeSegments() +} + +// normalizeWelcomeSegments 校验并规范化欢迎语段落;返回规范化列表与兼容用首段文本。 +func normalizeWelcomeSegments(input []WelcomeSegment) ([]WelcomeSegment, string, error) { + if len(input) == 0 { + return nil, "", fmt.Errorf("至少保留一段欢迎语") + } + if len(input) > maxWelcomeSegments { + return nil, "", fmt.Errorf("欢迎语最多 %d 段", maxWelcomeSegments) + } + out := make([]WelcomeSegment, 0, len(input)) + for i, seg := range input { + t := strings.ToLower(strings.TrimSpace(seg.Type)) + c := strings.TrimSpace(seg.Content) + switch t { + case "text": + if c == "" { + return nil, "", fmt.Errorf("第 %d 段文本不能为空", i+1) + } + if utf8.RuneCountInString(c) > maxWelcomeTextLen { + return nil, "", fmt.Errorf("第 %d 段文本不能超过 %d 字", i+1, maxWelcomeTextLen) + } + out = append(out, WelcomeSegment{Type: "text", Content: c}) + case "image": + validated, err := validateMessageContent("image", c) + if err != nil { + return nil, "", fmt.Errorf("第 %d 段图片:%s", i+1, err.Error()) + } + out = append(out, WelcomeSegment{Type: "image", Content: validated}) + default: + return nil, "", fmt.Errorf("第 %d 段类型无效,请使用 text 或 image", i+1) + } + } + return out, firstWelcomeText(out), nil +} + func defaultWorkHoursJSON() string { // 默认全周全天可接待,租户可在设置中收紧为工作日时段 hours := map[string]string{ @@ -50,19 +140,20 @@ func getOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) { displayName = "在线客服" } setting = model.TenantSetting{ - TenantID: tenantID, - DisplayName: displayName, - AgentNickname: defaultAgentNickname, - Timezone: defaultTimezone, - WelcomeMessage: defaultWelcomeMessage, - OfflinePrompt: defaultOfflinePrompt, - WorkHoursJSON: defaultWorkHoursJSON(), - WorktimePrompt: defaultWorktimePrompt, - NotifyNewSession: true, - NotifyOfflineLeave: true, - NotifyDailyReport: false, - AssignStrategy: assignStrategyLeastLoad, - MaxActivePerAgent: 0, + TenantID: tenantID, + DisplayName: displayName, + AgentNickname: defaultAgentNickname, + Timezone: defaultTimezone, + WelcomeMessage: defaultWelcomeMessage, + WelcomeMessagesJSON: defaultWelcomeMessagesJSON(), + OfflinePrompt: defaultOfflinePrompt, + WorkHoursJSON: defaultWorkHoursJSON(), + WorktimePrompt: defaultWorktimePrompt, + NotifyNewSession: true, + NotifyOfflineLeave: true, + NotifyDailyReport: false, + AssignStrategy: assignStrategyLeastLoad, + MaxActivePerAgent: 0, } if err := model.DB.Create(&setting).Error; err != nil { return nil, err @@ -173,12 +264,14 @@ func settingsResponse(setting *model.TenantSetting) gin.H { if maxActive < 0 { maxActive = 0 } + welcomeSegs := resolveWelcomeSegments(setting) return gin.H{ "tenant_id": setting.TenantID, "display_name": setting.DisplayName, "agent_nickname": setting.AgentNickname, "timezone": setting.Timezone, - "welcome_message": setting.WelcomeMessage, + "welcome_message": firstWelcomeText(welcomeSegs), + "welcome_messages": welcomeSegs, "offline_prompt": setting.OfflinePrompt, "work_hours": parseWorkHours(setting.WorkHoursJSON), "worktime_prompt": setting.WorktimePrompt, @@ -209,6 +302,7 @@ type updateSettingsReq struct { AgentNickname *string `json:"agent_nickname"` Timezone *string `json:"timezone"` WelcomeMessage *string `json:"welcome_message"` + WelcomeMessages *[]WelcomeSegment `json:"welcome_messages"` OfflinePrompt *string `json:"offline_prompt"` WorkHours map[string]string `json:"work_hours"` WorktimePrompt *string `json:"worktime_prompt"` @@ -275,13 +369,35 @@ func (h *SettingsHandler) Update(c *gin.Context) { } updates["timezone"] = tz } - if req.WelcomeMessage != nil { + // 优先多段欢迎语;若仅传旧 welcome_message 则同步为单段文本 + if req.WelcomeMessages != nil { + segs, firstText, err := normalizeWelcomeSegments(*req.WelcomeMessages) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return + } + b, err := json.Marshal(segs) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语序列化失败"}) + return + } + updates["welcome_messages_json"] = string(b) + updates["welcome_message"] = firstText + } else if req.WelcomeMessage != nil { msg := strings.TrimSpace(*req.WelcomeMessage) if len([]rune(msg)) > 500 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语不能超过 500 字"}) return } updates["welcome_message"] = msg + // 同步为单段,避免旧字段与 JSON 不一致 + if msg == "" { + updates["welcome_messages_json"] = defaultWelcomeMessagesJSON() + updates["welcome_message"] = defaultWelcomeMessage + } else { + b, _ := json.Marshal([]WelcomeSegment{{Type: "text", Content: msg}}) + updates["welcome_messages_json"] = string(b) + } } if req.OfflinePrompt != nil { msg := strings.TrimSpace(*req.OfflinePrompt) diff --git a/server/internal/handler/settings_welcome_test.go b/server/internal/handler/settings_welcome_test.go new file mode 100644 index 0000000..cd8697e --- /dev/null +++ b/server/internal/handler/settings_welcome_test.go @@ -0,0 +1,68 @@ +package handler + +import ( + "testing" + + "kefu-cloud/server/internal/model" +) + +func TestNormalizeWelcomeSegments(t *testing.T) { + SetImagePublicBase("http://localhost:9100/kefu") + + _, _, err := normalizeWelcomeSegments(nil) + if err == nil { + t.Fatal("空列表应失败") + } + + ok, first, err := normalizeWelcomeSegments([]WelcomeSegment{ + {Type: "text", Content: " 你好 "}, + {Type: "image", Content: "http://localhost:9100/kefu/tenants/1/welcome/a.webp"}, + {Type: "text", Content: "请描述问题"}, + }) + if err != nil { + t.Fatalf("合法多段应通过: %v", err) + } + if len(ok) != 3 || ok[0].Content != "你好" || first != "你好" { + t.Fatalf("规范化结果异常: %+v first=%s", ok, first) + } + + _, _, err = normalizeWelcomeSegments([]WelcomeSegment{ + {Type: "image", Content: "https://evil.example/x.png"}, + }) + if err == nil { + t.Fatal("非白名单图片 URL 应失败") + } + + tooMany := make([]WelcomeSegment, maxWelcomeSegments+1) + for i := range tooMany { + tooMany[i] = WelcomeSegment{Type: "text", Content: "x"} + } + if _, _, err := normalizeWelcomeSegments(tooMany); err == nil { + t.Fatal("超过段数上限应失败") + } +} + +func TestResolveWelcomeSegmentsCompat(t *testing.T) { + // 仅有旧字段 + s1 := &model.TenantSetting{WelcomeMessage: "旧欢迎语"} + segs := resolveWelcomeSegments(s1) + if len(segs) != 1 || segs[0].Type != "text" || segs[0].Content != "旧欢迎语" { + t.Fatalf("旧字段兼容失败: %+v", segs) + } + + // 优先 JSON + s2 := &model.TenantSetting{ + WelcomeMessage: "旧", + WelcomeMessagesJSON: `[{"type":"text","content":"新1"},{"type":"image","content":"http://x/a.webp"}]`, + } + segs = resolveWelcomeSegments(s2) + if len(segs) != 2 || segs[0].Content != "新1" { + t.Fatalf("JSON 优先失败: %+v", segs) + } + + // 空 → 默认 + segs = resolveWelcomeSegments(&model.TenantSetting{}) + if len(segs) != 1 || segs[0].Content != defaultWelcomeMessage { + t.Fatalf("默认欢迎语失败: %+v", segs) + } +} diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 99e5a46..7583ea0 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -148,15 +148,13 @@ func (h *WidgetHandler) Init(c *gin.Context) { ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) } - welcome := defaultWelcomeMessage + welcomeSegs := resolveWelcomeSegments(setting) + welcome := firstWelcomeText(welcomeSegs) offlinePrompt := offlineLeavePrompt agentNickname := defaultAgentNickname displayName := "在线客服" worktimePrompt := defaultWorktimePrompt if setting != nil { - if setting.WelcomeMessage != "" { - welcome = setting.WelcomeMessage - } if setting.OfflinePrompt != "" { offlinePrompt = setting.OfflinePrompt } @@ -186,6 +184,7 @@ func (h *WidgetHandler) Init(c *gin.Context) { "within_work_hours": withinWorkHours, "session_status": session.Status, "welcome_message": welcome, + "welcome_messages": welcomeSegs, "display_name": displayName, "agent_nickname": agentNickname, } diff --git a/server/internal/model/models.go b/server/internal/model/models.go index 83952a5..3bbebea 100644 --- a/server/internal/model/models.go +++ b/server/internal/model/models.go @@ -164,8 +164,10 @@ type TenantSetting struct { AgentNickname string `gorm:"size:50" json:"agent_nickname"` Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"` WelcomeMessage string `gorm:"size:500" json:"welcome_message"` - OfflinePrompt string `gorm:"size:500" json:"offline_prompt"` - WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"` + // WelcomeMessagesJSON 多段欢迎语 JSON:[{type:text|image, content:...}, ...] + WelcomeMessagesJSON string `gorm:"type:text" json:"welcome_messages_json"` + OfflinePrompt string `gorm:"size:500" json:"offline_prompt"` + WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"` WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"` NotifyNewSession bool `gorm:"default:true" json:"notify_new_session"` NotifyOfflineLeave bool `gorm:"default:true" json:"notify_offline_leave"` diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index b1ffe00..ce357a2 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -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 = { admin: '管理员', supervisor: '主管', @@ -98,7 +114,7 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] { key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: }, { key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: }, { key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: }, - { key: 'autoreply', label: '自动回复', desc: '欢迎语与离线留言提示', icon: }, + { key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: }, { key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: }, { key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: }, ] @@ -129,6 +145,10 @@ const Settings = () => { const [staffModalOpen, setStaffModalOpen] = useState(false) const [editingStaff, setEditingStaff] = useState(null) const [savingStaff, setSavingStaff] = useState(false) + const [welcomeSegments, setWelcomeSegments] = useState([ + { type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }, + ]) + const [uploadingWelcomeIdx, setUploadingWelcomeIdx] = useState(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 = { 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 = () => {
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, + }, '自动回复已保存') + }} > - - - +
+
+
+
欢迎语(多段)
+

+ 访客打开窗口且尚无消息时按顺序展示;可混排文本与图片,最多 {MAX_WELCOME_SEGMENTS} 段。 +

+
+
+
+ {welcomeSegments.map((seg, idx) => ( +
+
+ + 第 {idx + 1} 段 · {seg.type === 'image' ? '图片' : '文本'} + +
+
+
+ {seg.type === 'text' ? ( + { + const v = e.target.value + setWelcomeSegments(prev => + prev.map((s, i) => (i === idx ? { ...s, content: v } : s)), + ) + }} + /> + ) : ( +
+ {seg.content ? ( + {`欢迎图 + ) : ( +
+ 未上传 +
+ )} + { + 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) + } + }} + > + + +
+ )} +
+ ))} +
+
+ + +
+
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index fe6f991..7533b00 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -354,12 +354,21 @@ export const deleteStaff = (id: number) => del(`/staff/${id}`) export type WorkHours = Record 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('/settings') -export const updateTenantSettings = (data: Partial & { work_hours?: WorkHours }) => - put('/settings', data) +export const updateTenantSettings = ( + data: Partial & { work_hours?: WorkHours; welcome_messages?: WelcomeSegment[] }, +) => put('/settings', data) // Statistics export const getKPIs = () => get('/statistics/kpi') diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index a186f96..f46055c 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -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(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 = ({
{showWelcome && ( -
-
-

- {agentsOnline ? welcomeMessage : offlinePrompt} -

-
+
+ {agentsOnline ? ( + welcomeSegments.map((seg, i) => + seg.type === 'image' ? ( +
+ +
+ ) : ( +
+ {seg.content} +
+ ), + ) + ) : ( +
+ {offlinePrompt} +
+ )}
)}