欢迎语支持多段配置与图片
租户可配置有序文本/图片欢迎语;设置页支持增删排序与上传,Widget 空会话按序展示;兼容旧 welcome_message 字段。
This commit is contained in:
@@ -748,9 +748,9 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
|||||||
t.Fatalf("创建渠道失败: %v", err)
|
t.Fatalf("创建渠道失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存欢迎语与离线提示
|
// 保存多段欢迎语与离线提示
|
||||||
saveRec := httptest.NewRecorder()
|
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))
|
router.ServeHTTP(saveRec, bearerRequest(t, http.MethodPut, "/api/settings", []byte(saveBody), admin))
|
||||||
if saveRec.Code != http.StatusOK {
|
if saveRec.Code != http.StatusOK {
|
||||||
t.Fatalf("保存设置失败: %s", saveRec.Body.String())
|
t.Fatalf("保存设置失败: %s", saveRec.Body.String())
|
||||||
@@ -764,6 +764,10 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
|||||||
var getResp struct {
|
var getResp struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
WelcomeMessage string `json:"welcome_message"`
|
WelcomeMessage string `json:"welcome_message"`
|
||||||
|
WelcomeMessages []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"welcome_messages"`
|
||||||
OfflinePrompt string `json:"offline_prompt"`
|
OfflinePrompt string `json:"offline_prompt"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
@@ -773,6 +777,9 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
|||||||
if getResp.Data.WelcomeMessage != "定制欢迎语ABC" || getResp.Data.OfflinePrompt != "定制离线提示XYZ" {
|
if getResp.Data.WelcomeMessage != "定制欢迎语ABC" || getResp.Data.OfflinePrompt != "定制离线提示XYZ" {
|
||||||
t.Fatalf("设置未持久化: %+v", getResp.Data)
|
t.Fatalf("设置未持久化: %+v", getResp.Data)
|
||||||
}
|
}
|
||||||
|
if len(getResp.Data.WelcomeMessages) != 2 || getResp.Data.WelcomeMessages[1].Content != "第二段欢迎" {
|
||||||
|
t.Fatalf("多段欢迎语未持久化: %+v", getResp.Data.WelcomeMessages)
|
||||||
|
}
|
||||||
|
|
||||||
// 无在线客服时 init 应返回定制离线提示
|
// 无在线客服时 init 应返回定制离线提示
|
||||||
initRec := httptest.NewRecorder()
|
initRec := httptest.NewRecorder()
|
||||||
@@ -785,6 +792,10 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
|||||||
var initResp struct {
|
var initResp struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
WelcomeMessage string `json:"welcome_message"`
|
WelcomeMessage string `json:"welcome_message"`
|
||||||
|
WelcomeMessages []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"welcome_messages"`
|
||||||
OfflinePrompt string `json:"offline_prompt"`
|
OfflinePrompt string `json:"offline_prompt"`
|
||||||
AgentsOnline bool `json:"agents_online"`
|
AgentsOnline bool `json:"agents_online"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
@@ -798,4 +809,7 @@ func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
|
|||||||
if initResp.Data.WelcomeMessage != "定制欢迎语ABC" || initResp.Data.OfflinePrompt != "定制离线提示XYZ" {
|
if initResp.Data.WelcomeMessage != "定制欢迎语ABC" || initResp.Data.OfflinePrompt != "定制离线提示XYZ" {
|
||||||
t.Fatalf("widget 未使用租户设置: %+v", initResp.Data)
|
t.Fatalf("widget 未使用租户设置: %+v", initResp.Data)
|
||||||
}
|
}
|
||||||
|
if len(initResp.Data.WelcomeMessages) != 2 {
|
||||||
|
t.Fatalf("widget 未返回多段欢迎语: %+v", initResp.Data.WelcomeMessages)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"kefu-cloud/server/internal/middleware"
|
"kefu-cloud/server/internal/middleware"
|
||||||
@@ -22,10 +23,99 @@ const (
|
|||||||
defaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。"
|
defaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。"
|
||||||
defaultAgentNickname = "在线客服"
|
defaultAgentNickname = "在线客服"
|
||||||
defaultTimezone = "Asia/Shanghai"
|
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"}
|
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 {
|
func defaultWorkHoursJSON() string {
|
||||||
// 默认全周全天可接待,租户可在设置中收紧为工作日时段
|
// 默认全周全天可接待,租户可在设置中收紧为工作日时段
|
||||||
hours := map[string]string{
|
hours := map[string]string{
|
||||||
@@ -55,6 +145,7 @@ func getOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) {
|
|||||||
AgentNickname: defaultAgentNickname,
|
AgentNickname: defaultAgentNickname,
|
||||||
Timezone: defaultTimezone,
|
Timezone: defaultTimezone,
|
||||||
WelcomeMessage: defaultWelcomeMessage,
|
WelcomeMessage: defaultWelcomeMessage,
|
||||||
|
WelcomeMessagesJSON: defaultWelcomeMessagesJSON(),
|
||||||
OfflinePrompt: defaultOfflinePrompt,
|
OfflinePrompt: defaultOfflinePrompt,
|
||||||
WorkHoursJSON: defaultWorkHoursJSON(),
|
WorkHoursJSON: defaultWorkHoursJSON(),
|
||||||
WorktimePrompt: defaultWorktimePrompt,
|
WorktimePrompt: defaultWorktimePrompt,
|
||||||
@@ -173,12 +264,14 @@ func settingsResponse(setting *model.TenantSetting) gin.H {
|
|||||||
if maxActive < 0 {
|
if maxActive < 0 {
|
||||||
maxActive = 0
|
maxActive = 0
|
||||||
}
|
}
|
||||||
|
welcomeSegs := resolveWelcomeSegments(setting)
|
||||||
return gin.H{
|
return gin.H{
|
||||||
"tenant_id": setting.TenantID,
|
"tenant_id": setting.TenantID,
|
||||||
"display_name": setting.DisplayName,
|
"display_name": setting.DisplayName,
|
||||||
"agent_nickname": setting.AgentNickname,
|
"agent_nickname": setting.AgentNickname,
|
||||||
"timezone": setting.Timezone,
|
"timezone": setting.Timezone,
|
||||||
"welcome_message": setting.WelcomeMessage,
|
"welcome_message": firstWelcomeText(welcomeSegs),
|
||||||
|
"welcome_messages": welcomeSegs,
|
||||||
"offline_prompt": setting.OfflinePrompt,
|
"offline_prompt": setting.OfflinePrompt,
|
||||||
"work_hours": parseWorkHours(setting.WorkHoursJSON),
|
"work_hours": parseWorkHours(setting.WorkHoursJSON),
|
||||||
"worktime_prompt": setting.WorktimePrompt,
|
"worktime_prompt": setting.WorktimePrompt,
|
||||||
@@ -209,6 +302,7 @@ type updateSettingsReq struct {
|
|||||||
AgentNickname *string `json:"agent_nickname"`
|
AgentNickname *string `json:"agent_nickname"`
|
||||||
Timezone *string `json:"timezone"`
|
Timezone *string `json:"timezone"`
|
||||||
WelcomeMessage *string `json:"welcome_message"`
|
WelcomeMessage *string `json:"welcome_message"`
|
||||||
|
WelcomeMessages *[]WelcomeSegment `json:"welcome_messages"`
|
||||||
OfflinePrompt *string `json:"offline_prompt"`
|
OfflinePrompt *string `json:"offline_prompt"`
|
||||||
WorkHours map[string]string `json:"work_hours"`
|
WorkHours map[string]string `json:"work_hours"`
|
||||||
WorktimePrompt *string `json:"worktime_prompt"`
|
WorktimePrompt *string `json:"worktime_prompt"`
|
||||||
@@ -275,13 +369,35 @@ func (h *SettingsHandler) Update(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
updates["timezone"] = tz
|
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)
|
msg := strings.TrimSpace(*req.WelcomeMessage)
|
||||||
if len([]rune(msg)) > 500 {
|
if len([]rune(msg)) > 500 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语不能超过 500 字"})
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语不能超过 500 字"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
updates["welcome_message"] = msg
|
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 {
|
if req.OfflinePrompt != nil {
|
||||||
msg := strings.TrimSpace(*req.OfflinePrompt)
|
msg := strings.TrimSpace(*req.OfflinePrompt)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,15 +148,13 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
|||||||
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
welcome := defaultWelcomeMessage
|
welcomeSegs := resolveWelcomeSegments(setting)
|
||||||
|
welcome := firstWelcomeText(welcomeSegs)
|
||||||
offlinePrompt := offlineLeavePrompt
|
offlinePrompt := offlineLeavePrompt
|
||||||
agentNickname := defaultAgentNickname
|
agentNickname := defaultAgentNickname
|
||||||
displayName := "在线客服"
|
displayName := "在线客服"
|
||||||
worktimePrompt := defaultWorktimePrompt
|
worktimePrompt := defaultWorktimePrompt
|
||||||
if setting != nil {
|
if setting != nil {
|
||||||
if setting.WelcomeMessage != "" {
|
|
||||||
welcome = setting.WelcomeMessage
|
|
||||||
}
|
|
||||||
if setting.OfflinePrompt != "" {
|
if setting.OfflinePrompt != "" {
|
||||||
offlinePrompt = setting.OfflinePrompt
|
offlinePrompt = setting.OfflinePrompt
|
||||||
}
|
}
|
||||||
@@ -186,6 +184,7 @@ func (h *WidgetHandler) Init(c *gin.Context) {
|
|||||||
"within_work_hours": withinWorkHours,
|
"within_work_hours": withinWorkHours,
|
||||||
"session_status": session.Status,
|
"session_status": session.Status,
|
||||||
"welcome_message": welcome,
|
"welcome_message": welcome,
|
||||||
|
"welcome_messages": welcomeSegs,
|
||||||
"display_name": displayName,
|
"display_name": displayName,
|
||||||
"agent_nickname": agentNickname,
|
"agent_nickname": agentNickname,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ type TenantSetting struct {
|
|||||||
AgentNickname string `gorm:"size:50" json:"agent_nickname"`
|
AgentNickname string `gorm:"size:50" json:"agent_nickname"`
|
||||||
Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"`
|
Timezone string `gorm:"size:50;default:Asia/Shanghai" json:"timezone"`
|
||||||
WelcomeMessage string `gorm:"size:500" json:"welcome_message"`
|
WelcomeMessage string `gorm:"size:500" json:"welcome_message"`
|
||||||
|
// WelcomeMessagesJSON 多段欢迎语 JSON:[{type:text|image, content:...}, ...]
|
||||||
|
WelcomeMessagesJSON string `gorm:"type:text" json:"welcome_messages_json"`
|
||||||
OfflinePrompt string `gorm:"size:500" json:"offline_prompt"`
|
OfflinePrompt string `gorm:"size:500" json:"offline_prompt"`
|
||||||
WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"`
|
WorkHoursJSON string `gorm:"type:text" json:"work_hours_json"`
|
||||||
WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"`
|
WorktimePrompt string `gorm:"size:500" json:"worktime_prompt"`
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react'
|
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 {
|
import {
|
||||||
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined,
|
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined,
|
||||||
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
|
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
|
||||||
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
||||||
EditOutlined, StopOutlined, CheckCircleOutlined,
|
EditOutlined, StopOutlined, CheckCircleOutlined, DeleteOutlined,
|
||||||
|
ArrowUpOutlined, ArrowDownOutlined, PictureOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import dayjs, { type Dayjs } from 'dayjs'
|
import dayjs, { type Dayjs } from 'dayjs'
|
||||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||||
import {
|
import {
|
||||||
createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings,
|
createChannel, createStaff, deleteStaff, getChannels, getStaff, getTenantSettings,
|
||||||
updateChannel, updateStaff, updateTenantSettings,
|
updateChannel, updateStaff, updateTenantSettings, uploadImage,
|
||||||
type Channel, type StaffUser, type TenantSettings, type WorkHours,
|
type Channel, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
|
||||||
} from '@/services/api'
|
} from '@/services/api'
|
||||||
import { useAuth } from '@/stores/auth'
|
import { useAuth } from '@/stores/auth'
|
||||||
|
|
||||||
@@ -80,6 +81,21 @@ function defaultCustomRange(): [Dayjs, Dayjs] {
|
|||||||
return [dayjs('09:00', 'HH:mm'), dayjs('18:00', 'HH:mm')]
|
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> = {
|
const roleLabel: Record<string, string> = {
|
||||||
admin: '管理员',
|
admin: '管理员',
|
||||||
supervisor: '主管',
|
supervisor: '主管',
|
||||||
@@ -98,7 +114,7 @@ const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[]
|
|||||||
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
||||||
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
||||||
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
|
{ 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: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
||||||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||||||
]
|
]
|
||||||
@@ -129,6 +145,10 @@ const Settings = () => {
|
|||||||
const [staffModalOpen, setStaffModalOpen] = useState(false)
|
const [staffModalOpen, setStaffModalOpen] = useState(false)
|
||||||
const [editingStaff, setEditingStaff] = useState<StaffUser | null>(null)
|
const [editingStaff, setEditingStaff] = useState<StaffUser | null>(null)
|
||||||
const [savingStaff, setSavingStaff] = useState(false)
|
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]
|
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
|
||||||
|
|
||||||
@@ -173,9 +193,9 @@ const Settings = () => {
|
|||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
})
|
})
|
||||||
autoReplyForm.setFieldsValue({
|
autoReplyForm.setFieldsValue({
|
||||||
welcome_message: data.welcome_message,
|
|
||||||
offline_prompt: data.offline_prompt,
|
offline_prompt: data.offline_prompt,
|
||||||
})
|
})
|
||||||
|
setWelcomeSegments(normalizeWelcomeFromSettings(data))
|
||||||
{
|
{
|
||||||
const workFields: Record<string, unknown> = {
|
const workFields: Record<string, unknown> = {
|
||||||
worktime_prompt: data.worktime_prompt,
|
worktime_prompt: data.worktime_prompt,
|
||||||
@@ -284,6 +304,9 @@ const Settings = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await updateTenantSettings(payload)
|
const res = await updateTenantSettings(payload)
|
||||||
setSettings(res.data)
|
setSettings(res.data)
|
||||||
|
if (payload.welcome_messages || res.data?.welcome_messages) {
|
||||||
|
setWelcomeSegments(normalizeWelcomeFromSettings(res.data))
|
||||||
|
}
|
||||||
message.success(okText)
|
message.success(okText)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '保存失败')
|
message.error(e instanceof Error ? e.message : '保存失败')
|
||||||
@@ -764,14 +787,185 @@ const Settings = () => {
|
|||||||
<Form
|
<Form
|
||||||
form={autoReplyForm}
|
form={autoReplyForm}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={values => savePartial({
|
onFinish={async values => {
|
||||||
welcome_message: values.welcome_message,
|
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,
|
offline_prompt: values.offline_prompt,
|
||||||
}, '自动回复已保存')}
|
}, '自动回复已保存')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item name="welcome_message" label="欢迎语" rules={[{ max: 500 }]}>
|
<div className="mb-5">
|
||||||
<Input.TextArea rows={3} maxLength={500} showCount placeholder="访客打开窗口时展示" />
|
<div className="flex items-center justify-between mb-2">
|
||||||
</Form.Item>
|
<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 }]}>
|
<Form.Item name="offline_prompt" label="离线留言提示" rules={[{ max: 500 }]}>
|
||||||
<Input.TextArea rows={3} maxLength={500} showCount placeholder="无客服在线时展示" />
|
<Input.TextArea rows={3} maxLength={500} showCount placeholder="无客服在线时展示" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
+12
-2
@@ -354,12 +354,21 @@ export const deleteStaff = (id: number) => del(`/staff/${id}`)
|
|||||||
export type WorkHours = Record<string, string>
|
export type WorkHours = Record<string, string>
|
||||||
export type AssignStrategy = 'least_load' | 'round_robin'
|
export type AssignStrategy = 'least_load' | 'round_robin'
|
||||||
|
|
||||||
|
/** 欢迎语段落:文本或图片 URL */
|
||||||
|
export type WelcomeSegment = {
|
||||||
|
type: 'text' | 'image'
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface TenantSettings {
|
export interface TenantSettings {
|
||||||
tenant_id: number
|
tenant_id: number
|
||||||
display_name: string
|
display_name: string
|
||||||
agent_nickname: string
|
agent_nickname: string
|
||||||
timezone: string
|
timezone: string
|
||||||
|
/** 兼容字段:首段文本欢迎语 */
|
||||||
welcome_message: string
|
welcome_message: string
|
||||||
|
/** 多段欢迎语(含图片) */
|
||||||
|
welcome_messages?: WelcomeSegment[]
|
||||||
offline_prompt: string
|
offline_prompt: string
|
||||||
work_hours: WorkHours
|
work_hours: WorkHours
|
||||||
worktime_prompt: string
|
worktime_prompt: string
|
||||||
@@ -372,8 +381,9 @@ export interface TenantSettings {
|
|||||||
max_active_per_agent?: number
|
max_active_per_agent?: number
|
||||||
}
|
}
|
||||||
export const getTenantSettings = () => get<TenantSettings>('/settings')
|
export const getTenantSettings = () => get<TenantSettings>('/settings')
|
||||||
export const updateTenantSettings = (data: Partial<TenantSettings> & { work_hours?: WorkHours }) =>
|
export const updateTenantSettings = (
|
||||||
put<TenantSettings>('/settings', data)
|
data: Partial<TenantSettings> & { work_hours?: WorkHours; welcome_messages?: WelcomeSegment[] },
|
||||||
|
) => put<TenantSettings>('/settings', data)
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
|
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
|
||||||
|
|||||||
@@ -18,6 +18,34 @@ interface Message {
|
|||||||
|
|
||||||
const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
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'
|
type LayoutMode = 'floating' | 'fill'
|
||||||
|
|
||||||
interface VisitorChatProps {
|
interface VisitorChatProps {
|
||||||
@@ -76,7 +104,7 @@ const VisitorChat = ({
|
|||||||
const [sendError, setSendError] = useState('')
|
const [sendError, setSendError] = useState('')
|
||||||
const [agentsOnline, setAgentsOnline] = useState(true)
|
const [agentsOnline, setAgentsOnline] = useState(true)
|
||||||
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
|
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
|
||||||
const [welcomeMessage, setWelcomeMessage] = useState('您好!欢迎咨询,请问有什么可以帮您?')
|
const [welcomeSegments, setWelcomeSegments] = useState<WelcomeSegment[]>(defaultWelcomeSegments)
|
||||||
const [displayName, setDisplayName] = useState('在线客服')
|
const [displayName, setDisplayName] = useState('在线客服')
|
||||||
const [agentName, setAgentName] = useState('')
|
const [agentName, setAgentName] = useState('')
|
||||||
const [leaveName, setLeaveName] = useState('')
|
const [leaveName, setLeaveName] = useState('')
|
||||||
@@ -291,7 +319,7 @@ const VisitorChat = ({
|
|||||||
visitorTokenRef.current = token
|
visitorTokenRef.current = token
|
||||||
setAgentsOnline(Boolean(data.agents_online))
|
setAgentsOnline(Boolean(data.agents_online))
|
||||||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
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.display_name) setDisplayName(data.display_name)
|
||||||
if (data.agent_name) setAgentName(data.agent_name)
|
if (data.agent_name) setAgentName(data.agent_name)
|
||||||
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
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">
|
<section className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-neutral-50 no-scrollbar">
|
||||||
{showWelcome && (
|
{showWelcome && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-start gap-2 max-w-[90%]">
|
||||||
<div className="px-4 py-2 rounded-xl bg-white border border-neutral-200 max-w-[85%] text-center">
|
{agentsOnline ? (
|
||||||
<p className="m-0 text-[13px] text-neutral-600 leading-normal">
|
welcomeSegments.map((seg, i) =>
|
||||||
{agentsOnline ? welcomeMessage : offlinePrompt}
|
seg.type === 'image' ? (
|
||||||
</p>
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user