实现租户系统设置落库:欢迎语、工作时间与通知开关

- 新增 TenantSetting 模型与 /api/settings 读写接口
- 设置页接通基本资料、自动回复、工作时间、通知偏好
- Widget 初始化使用欢迎语/离线提示,非工作时间不自动分配
- 补充设置持久化与 Widget 提示集成测试
This commit is contained in:
yml2213
2026-07-15 11:27:15 +08:00
parent b5203f7cbd
commit 318ba8a563
9 changed files with 686 additions and 97 deletions
+6
View File
@@ -13,6 +13,7 @@ func SetupRoutes(r *gin.Engine) {
stats := NewStatisticsHandler()
admin := NewAdminHandler()
channel := NewChannelHandler()
settings := NewSettingsHandler()
ws := NewWsHandler()
widget := NewWidgetHandler()
@@ -75,6 +76,11 @@ func SetupRoutes(r *gin.Engine) {
channels.POST("", channel.Create)
channels.PUT("/:id", channel.Update)
// 租户系统设置
settingsGroup := authRequired.Group("/settings")
settingsGroup.GET("", settings.Get)
settingsGroup.PUT("", settings.Update)
// 统计
statistics := authRequired.Group("/statistics")
statistics.GET("/kpi", stats.KPIs)
@@ -682,3 +682,67 @@ func TestAdminTenantLifecycleAndPlanToggle(t *testing.T) {
t.Fatalf("套餐状态未更新: %+v", updatedPlan)
}
}
func TestTenantSettingsPersistAndAffectWidgetPrompt(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "设置租户", "normal")
admin := createUser(t, tenant.ID, "settings-admin", "admin")
channel := model.Channel{
TenantID: tenant.ID, Type: "web", Name: "网页", Status: "enabled",
ScriptCode: `<script data-id="WK_set_001"></script>`,
}
if err := model.DB.Create(&channel).Error; err != nil {
t.Fatalf("创建渠道失败: %v", err)
}
// 保存欢迎语与离线提示
saveRec := httptest.NewRecorder()
saveBody := `{"welcome_message":"定制欢迎语ABC","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())
}
getRec := httptest.NewRecorder()
router.ServeHTTP(getRec, bearerRequest(t, http.MethodGet, "/api/settings", nil, admin))
if getRec.Code != http.StatusOK {
t.Fatalf("读取设置失败: %s", getRec.Body.String())
}
var getResp struct {
Data struct {
WelcomeMessage string `json:"welcome_message"`
OfflinePrompt string `json:"offline_prompt"`
} `json:"data"`
}
if err := json.Unmarshal(getRec.Body.Bytes(), &getResp); err != nil {
t.Fatalf("解析设置失败: %v", err)
}
if getResp.Data.WelcomeMessage != "定制欢迎语ABC" || getResp.Data.OfflinePrompt != "定制离线提示XYZ" {
t.Fatalf("设置未持久化: %+v", getResp.Data)
}
// 无在线客服时 init 应返回定制离线提示
initRec := httptest.NewRecorder()
initReq := httptest.NewRequest(http.MethodPost, "/api/widget/init", bytes.NewBufferString(`{"channel_key":"WK_set_001"}`))
initReq.Header.Set("Content-Type", "application/json")
router.ServeHTTP(initRec, initReq)
if initRec.Code != http.StatusOK {
t.Fatalf("widget init 失败: %s", initRec.Body.String())
}
var initResp struct {
Data struct {
WelcomeMessage string `json:"welcome_message"`
OfflinePrompt string `json:"offline_prompt"`
AgentsOnline bool `json:"agents_online"`
} `json:"data"`
}
if err := json.Unmarshal(initRec.Body.Bytes(), &initResp); err != nil {
t.Fatalf("解析 init 失败: %v", err)
}
if initResp.Data.AgentsOnline {
t.Fatalf("无客服时应离线")
}
if initResp.Data.WelcomeMessage != "定制欢迎语ABC" || initResp.Data.OfflinePrompt != "定制离线提示XYZ" {
t.Fatalf("widget 未使用租户设置: %+v", initResp.Data)
}
}
+298
View File
@@ -0,0 +1,298 @@
package handler
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"kefu-sys/server/internal/middleware"
"kefu-sys/server/internal/model"
)
type SettingsHandler struct{}
func NewSettingsHandler() *SettingsHandler { return &SettingsHandler{} }
const (
defaultWelcomeMessage = "您好!欢迎咨询,请问有什么可以帮您?"
defaultOfflinePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。"
defaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。"
defaultAgentNickname = "在线客服"
defaultTimezone = "Asia/Shanghai"
)
var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
func defaultWorkHoursJSON() string {
// 默认全周全天可接待,租户可在设置中收紧为工作日时段
hours := map[string]string{
"monday": "全天", "tuesday": "全天", "wednesday": "全天",
"thursday": "全天", "friday": "全天", "saturday": "全天", "sunday": "全天",
}
b, _ := json.Marshal(hours)
return string(b)
}
func getOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) {
var setting model.TenantSetting
err := model.DB.Where("tenant_id = ?", tenantID).First(&setting).Error
if err == nil {
return &setting, nil
}
var tenant model.Tenant
_ = model.DB.First(&tenant, tenantID).Error
displayName := tenant.Name
if displayName == "" {
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,
}
if err := model.DB.Create(&setting).Error; err != nil {
return nil, err
}
return &setting, nil
}
func parseWorkHours(raw string) map[string]string {
result := map[string]string{}
if strings.TrimSpace(raw) == "" {
_ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result)
return result
}
if err := json.Unmarshal([]byte(raw), &result); err != nil {
_ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result)
}
return result
}
// isWithinWorkHours 根据租户工作时间配置判断当前是否在工作时段。
// 未配置或某日为空视为休息;时段格式 HH:MM-HH:MM。
func isWithinWorkHours(setting *model.TenantSetting, now time.Time) bool {
if setting == nil {
return true
}
loc, err := time.LoadLocation(setting.Timezone)
if err != nil || setting.Timezone == "" {
loc, _ = time.LoadLocation(defaultTimezone)
}
local := now.In(loc)
hours := parseWorkHours(setting.WorkHoursJSON)
var key string
switch local.Weekday() {
case time.Monday:
key = "monday"
case time.Tuesday:
key = "tuesday"
case time.Wednesday:
key = "wednesday"
case time.Thursday:
key = "thursday"
case time.Friday:
key = "friday"
case time.Saturday:
key = "saturday"
default:
key = "sunday"
}
slot := strings.TrimSpace(hours[key])
if slot == "" || slot == "休息" {
return false
}
if slot == "全天" {
return true
}
parts := strings.Split(slot, "-")
if len(parts) != 2 {
return true
}
start, err1 := time.ParseInLocation("15:04", strings.TrimSpace(parts[0]), loc)
end, err2 := time.ParseInLocation("15:04", strings.TrimSpace(parts[1]), loc)
if err1 != nil || err2 != nil {
return true
}
// 组合到当天日期
startAt := time.Date(local.Year(), local.Month(), local.Day(), start.Hour(), start.Minute(), 0, 0, loc)
endAt := time.Date(local.Year(), local.Month(), local.Day(), end.Hour(), end.Minute(), 0, 0, loc)
if !endAt.After(startAt) {
// 跨午夜:当前 >= start 或 < end
return !local.Before(startAt) || local.Before(endAt)
}
return !local.Before(startAt) && local.Before(endAt)
}
func settingsResponse(setting *model.TenantSetting) gin.H {
return gin.H{
"tenant_id": setting.TenantID,
"display_name": setting.DisplayName,
"agent_nickname": setting.AgentNickname,
"timezone": setting.Timezone,
"welcome_message": setting.WelcomeMessage,
"offline_prompt": setting.OfflinePrompt,
"work_hours": parseWorkHours(setting.WorkHoursJSON),
"worktime_prompt": setting.WorktimePrompt,
"notify_new_session": setting.NotifyNewSession,
"notify_offline_leave": setting.NotifyOfflineLeave,
"notify_daily_report": setting.NotifyDailyReport,
}
}
func (h *SettingsHandler) Get(c *gin.Context) {
tenantID := middleware.GetTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可查看设置"})
return
}
setting, err := getOrCreateTenantSettings(tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载设置失败"})
return
}
middleware.JSON(c, settingsResponse(setting))
}
type updateSettingsReq struct {
DisplayName *string `json:"display_name"`
AgentNickname *string `json:"agent_nickname"`
Timezone *string `json:"timezone"`
WelcomeMessage *string `json:"welcome_message"`
OfflinePrompt *string `json:"offline_prompt"`
WorkHours map[string]string `json:"work_hours"`
WorktimePrompt *string `json:"worktime_prompt"`
NotifyNewSession *bool `json:"notify_new_session"`
NotifyOfflineLeave *bool `json:"notify_offline_leave"`
NotifyDailyReport *bool `json:"notify_daily_report"`
}
func (h *SettingsHandler) Update(c *gin.Context) {
if !middleware.HasAnyRole(c, "admin") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可修改设置"})
return
}
tenantID := middleware.GetTenantID(c)
if tenantID == 0 {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可修改设置"})
return
}
setting, err := getOrCreateTenantSettings(tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载设置失败"})
return
}
var req updateSettingsReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
updates := map[string]interface{}{}
if req.DisplayName != nil {
name := strings.TrimSpace(*req.DisplayName)
if name == "" || len([]rune(name)) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "租户显示名称无效"})
return
}
updates["display_name"] = name
// 同步更新租户主表名称(唯一约束下若冲突则仅更新 setting)
var conflict int64
model.DB.Model(&model.Tenant{}).Where("name = ? AND id <> ?", name, tenantID).Count(&conflict)
if conflict == 0 {
model.DB.Model(&model.Tenant{}).Where("id = ?", tenantID).Update("name", name)
}
}
if req.AgentNickname != nil {
nick := strings.TrimSpace(*req.AgentNickname)
if len([]rune(nick)) > 50 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客服昵称过长"})
return
}
updates["agent_nickname"] = nick
}
if req.Timezone != nil {
tz := strings.TrimSpace(*req.Timezone)
if tz == "" {
tz = defaultTimezone
}
if _, err := time.LoadLocation(tz); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "时区无效"})
return
}
updates["timezone"] = tz
}
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
}
if req.OfflinePrompt != nil {
msg := strings.TrimSpace(*req.OfflinePrompt)
if len([]rune(msg)) > 500 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "离线提示不能超过 500 字"})
return
}
updates["offline_prompt"] = msg
}
if req.WorktimePrompt != nil {
msg := strings.TrimSpace(*req.WorktimePrompt)
if len([]rune(msg)) > 500 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "非工作时间提示不能超过 500 字"})
return
}
updates["worktime_prompt"] = msg
}
if req.WorkHours != nil {
normalized := map[string]string{}
for _, key := range weekdayKeys {
val := strings.TrimSpace(req.WorkHours[key])
if val == "" || val == "休息" || val == "全天" || strings.Contains(val, "-") {
normalized[key] = val
continue
}
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "工作时间格式无效,请使用 09:00-18:00 / 全天 / 空"})
return
}
b, err := json.Marshal(normalized)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "工作时间序列化失败"})
return
}
updates["work_hours_json"] = string(b)
}
if req.NotifyNewSession != nil {
updates["notify_new_session"] = *req.NotifyNewSession
}
if req.NotifyOfflineLeave != nil {
updates["notify_offline_leave"] = *req.NotifyOfflineLeave
}
if req.NotifyDailyReport != nil {
updates["notify_daily_report"] = *req.NotifyDailyReport
}
if len(updates) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
return
}
if err := model.DB.Model(setting).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存失败"})
return
}
_ = model.DB.Where("tenant_id = ?", tenantID).First(setting).Error
middleware.JSON(c, settingsResponse(setting))
}
+50 -12
View File
@@ -125,8 +125,13 @@ func (h *WidgetHandler) Init(c *gin.Context) {
return
}
setting, _ := getOrCreateTenantSettings(channel.TenantID)
withinWorkHours := isWithinWorkHours(setting, time.Now())
// 非工作时间不自动分配,走留言/排队提示
canServeNow := onlineCount > 0 && withinWorkHours
var assignedAgent *model.User
if onlineCount > 0 {
if canServeNow {
assignedAgent, err = tryAutoAssign(&session)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "自动分配失败"})
@@ -138,22 +143,55 @@ func (h *WidgetHandler) Init(c *gin.Context) {
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
}
agentsOnline := onlineCount > 0
resp := gin.H{
"session_id": session.ID,
"customer_id": customer.ID,
"channel_id": channel.ID,
"tenant_id": channel.TenantID,
"visitor_token": visitorToken,
"agents_online": agentsOnline,
"session_status": session.Status,
welcome := defaultWelcomeMessage
offlinePrompt := offlineLeavePrompt
agentNickname := defaultAgentNickname
displayName := "在线客服"
worktimePrompt := defaultWorktimePrompt
if setting != nil {
if setting.WelcomeMessage != "" {
welcome = setting.WelcomeMessage
}
if setting.OfflinePrompt != "" {
offlinePrompt = setting.OfflinePrompt
}
if setting.AgentNickname != "" {
agentNickname = setting.AgentNickname
}
if setting.DisplayName != "" {
displayName = setting.DisplayName
}
if setting.WorktimePrompt != "" {
worktimePrompt = setting.WorktimePrompt
}
}
if !agentsOnline {
resp["offline_prompt"] = offlineLeavePrompt
// 非工作时间优先展示非工作时间提示
if !withinWorkHours {
offlinePrompt = worktimePrompt
}
resp := gin.H{
"session_id": session.ID,
"customer_id": customer.ID,
"channel_id": channel.ID,
"tenant_id": channel.TenantID,
"visitor_token": visitorToken,
"agents_online": canServeNow,
"online_agent_count": onlineCount,
"within_work_hours": withinWorkHours,
"session_status": session.Status,
"welcome_message": welcome,
"display_name": displayName,
"agent_nickname": agentNickname,
}
if !canServeNow {
resp["offline_prompt"] = offlinePrompt
}
if assignedAgent != nil {
resp["agent_id"] = assignedAgent.ID
resp["agent_name"] = assignedAgent.Nickname
} else if canServeNow {
resp["agent_name"] = agentNickname
}
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
+1
View File
@@ -41,5 +41,6 @@ func Migrate(db *gorm.DB) error {
&Plan{},
&OperationLog{},
&Announcement{},
&TenantSetting{},
)
}
+18
View File
@@ -152,3 +152,21 @@ type Announcement struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TenantSetting 租户级系统设置(欢迎语、工作时间、通知开关等)。
type TenantSetting struct {
ID uint `gorm:"primaryKey" json:"id"`
TenantID uint `gorm:"uniqueIndex;not null" json:"tenant_id"`
DisplayName string `gorm:"size:100" json:"display_name"`
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"`
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"`
NotifyDailyReport bool `gorm:"default:false" json:"notify_daily_report"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+222 -80
View File
@@ -3,7 +3,10 @@ import { Form, Input, Switch, Select, Button, Card, Tag, message, Spin, Empty }
import {
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined,
} from '@ant-design/icons'
import { createChannel, getChannels, updateChannel, type Channel } from '@/services/api'
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: '网页聊天' },
@@ -13,12 +16,34 @@ const typeMeta: Record<string, { icon: React.ReactNode; label: string }> = {
email: { icon: <MailOutlined />, label: '邮件工单' },
}
const weekDays: { key: keyof WorkHours | string; label: string }[] = [
{ key: 'monday', label: '周一' },
{ key: 'tuesday', label: '周二' },
{ key: 'wednesday', label: '周三' },
{ key: 'thursday', label: '周四' },
{ key: 'friday', label: '周五' },
{ key: 'saturday', label: '周六' },
{ key: 'sunday', label: '周日' },
]
const hourOptions = [
{ value: '09:00-18:00', label: '09:00-18:00' },
{ value: '08:00-17:00', label: '08:00-17:00' },
{ value: '10:00-19:00', label: '10:00-19:00' },
{ value: '全天', label: '全天' },
]
const Settings = () => {
const [activeTab, setActiveTab] = useState('channels')
const [activeTab, setActiveTab] = useState('basic')
const [basicForm] = Form.useForm()
const [autoReplyForm] = Form.useForm()
const [workForm] = Form.useForm()
const [notifyForm] = Form.useForm()
const [channels, setChannels] = useState<Channel[]>([])
const [settings, setSettings] = useState<TenantSettings | null>(null)
const [loadingChannels, setLoadingChannels] = useState(false)
const [loadingSettings, setLoadingSettings] = useState(false)
const [saving, setSaving] = useState(false)
const [togglingId, setTogglingId] = useState<number | null>(null)
const tabItems = [
@@ -44,10 +69,56 @@ const Settings = () => {
}
}
const loadSettings = async () => {
setLoadingSettings(true)
try {
const res = await getTenantSettings()
const data = res.data
setSettings(data)
basicForm.setFieldsValue({
display_name: data.display_name,
agent_nickname: data.agent_nickname,
timezone: data.timezone || 'Asia/Shanghai',
language: 'zh-CN',
})
autoReplyForm.setFieldsValue({
welcome_message: data.welcome_message,
offline_prompt: data.offline_prompt,
})
workForm.setFieldsValue({
worktime_prompt: data.worktime_prompt,
...Object.fromEntries(weekDays.map(d => [d.key, data.work_hours?.[d.key] || undefined])),
})
notifyForm.setFieldsValue({
notify_new_session: data.notify_new_session,
notify_offline_leave: data.notify_offline_leave,
notify_daily_report: data.notify_daily_report,
})
} catch (e) {
message.error(e instanceof Error ? e.message : '加载设置失败')
} finally {
setLoadingSettings(false)
}
}
useEffect(() => {
if (activeTab === 'channels') loadChannels()
if (['basic', 'autoreply', 'worktime', 'notify'].includes(activeTab)) loadSettings()
}, [activeTab])
const savePartial = async (payload: Parameters<typeof updateTenantSettings>[0], okText = '已保存') => {
setSaving(true)
try {
const res = await updateTenantSettings(payload)
setSettings(res.data)
message.success(okText)
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
setSaving(false)
}
}
const copyScript = async (script: string) => {
try {
const origin = window.location.origin
@@ -110,23 +181,40 @@ const Settings = () => {
<div className="flex-1 overflow-auto p-6">
{activeTab === 'basic' && (
<Card title="基本设置" className="max-w-2xl">
<Form form={basicForm} layout="vertical" initialValues={{ name: '示例公司', nickname: '客服小助手', timezone: 'Asia/Shanghai', language: 'zh-CN' }}>
<Form.Item name="name" label="租户名称" rules={[{ required: true }]}>
<Input placeholder="公司名称" />
</Form.Item>
<Form.Item name="nickname" label="客服昵称">
<Input placeholder="客服在访客端显示的昵称" />
</Form.Item>
<Form.Item name="timezone" label="时区">
<Select options={[{ value: 'Asia/Shanghai', label: '东八区 (UTC+8)' }, { value: 'Asia/Tokyo', label: '东京 (UTC+9)' }]} />
</Form.Item>
<Form.Item name="language" label="语言">
<Select options={[{ value: 'zh-CN', label: '简体中文' }]} disabled />
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => message.info('租户基本资料接口将在后续版本接通')}></Button>
</Form.Item>
</Form>
{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>
)}
@@ -188,18 +276,11 @@ const Settings = () => {
</div>
)}
{ch.type !== 'web' && (
<div className="text-xs text-neutral-400">
</div>
<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)}
/>
<Switch checked={enabled} size="small" loading={togglingId === ch.id} onChange={v => toggleChannel(ch, v)} />
</div>
</div>
</Card>
@@ -240,9 +321,9 @@ const Settings = () => {
<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-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">
@@ -254,79 +335,140 @@ const Settings = () => {
<Switch disabled />
</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>
<p className="text-sm text-neutral-500 m-0">
/ / / 线
</p>
</Card>
)}
{activeTab === 'autoreply' && (
<Card title="自动回复" className="max-w-2xl">
<Form form={autoReplyForm} layout="vertical" initialValues={{ welcome: '您好!欢迎咨询,请问有什么可以帮您?', offline: '当前无客服在线,请留言并留下联系方式。' }}>
<Form.Item name="welcome" label="欢迎语">
<Input.TextArea rows={3} maxLength={500} showCount />
</Form.Item>
<Form.Item name="offline" label="离线留言提示">
<Input.TextArea rows={3} maxLength={500} showCount />
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => message.info('自动回复配置接口将在后续版本接通')}></Button>
</Form.Item>
</Form>
{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">
<p className="text-sm text-neutral-500 mb-4"></p>
{['周一', '周二', '周三', '周四', '周五', '周六', '周日'].map(day => (
<div key={day} className="flex items-center justify-between py-2 border-b border-neutral-50">
<span className="text-sm text-neutral-700 w-12">{day}</span>
<Select
defaultValue={day === '周六' || day === '周日' ? undefined : '09:00-18:00'}
placeholder="休息"
className="w-36"
size="small"
allowClear
disabled
options={['09:00-18:00', '08:00-17:00', '10:00-19:00', '全天'].map(v => ({ value: v, label: v }))}
/>
</div>
))}
{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}
/>
</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>
)}
</Card>
)}
{activeTab === 'notify' && (
<Card title="通知设置" className="max-w-2xl">
<div className="space-y-3">
{[
{ title: '新会话提醒', desc: '有新访客进线时桌面通知' },
{ title: '离线留言通知', desc: '访客提交离线留言时提醒' },
{ title: '日报推送', desc: '每日服务数据摘要' },
].map(item => (
<div key={item.title} className="flex items-center justify-between p-3 border border-neutral-100 rounded-lg">
<div>
<div className="text-sm font-medium text-neutral-800">{item.title}</div>
<div className="text-xs text-neutral-400">{item.desc}</div>
{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>
</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>
<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_daily_report" valuePropName="checked" className="!mb-0">
<Switch />
</Form.Item>
</div>
<Switch disabled defaultChecked={item.title !== '日报推送'} />
</div>
))}
<div className="text-xs text-neutral-400"></div>
</div>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}></Button>
</Form.Item>
</Form>
)}
</Card>
)}
</div>
+19
View File
@@ -142,6 +142,25 @@ export const getChannels = () => get<Channel[]>('/channels')
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/channels/${id}`, data)
// Tenant settings
export type WorkHours = Record<string, string>
export interface TenantSettings {
tenant_id: number
display_name: string
agent_nickname: string
timezone: string
welcome_message: string
offline_prompt: string
work_hours: WorkHours
worktime_prompt: string
notify_new_session: boolean
notify_offline_leave: boolean
notify_daily_report: boolean
}
export const getTenantSettings = () => get<TenantSettings>('/settings')
export const updateTenantSettings = (data: Partial<TenantSettings> & { work_hours?: WorkHours }) =>
put<TenantSettings>('/settings', data)
// Statistics
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`)
+8 -5
View File
@@ -56,6 +56,8 @@ const VisitorChat = ({
const [sendError, setSendError] = useState('')
const [agentsOnline, setAgentsOnline] = useState(true)
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
const [welcomeMessage, setWelcomeMessage] = useState('您好!欢迎咨询,请问有什么可以帮您?')
const [displayName, setDisplayName] = useState('在线客服')
const [agentName, setAgentName] = useState('')
const [leaveName, setLeaveName] = useState('')
const [leavePhone, setLeavePhone] = useState('')
@@ -112,7 +114,10 @@ const VisitorChat = ({
setVisitorToken(token)
setAgentsOnline(Boolean(data.agents_online))
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
if (data.welcome_message) setWelcomeMessage(data.welcome_message)
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)
if (data.session_status === 'ended') setSessionEnded(true)
localStorage.setItem(storageKey, String(sid))
localStorage.setItem(tokenKey, token)
@@ -365,7 +370,7 @@ const VisitorChat = ({
<CustomerServiceOutlined className="text-lg text-white" />
</div>
<div className="flex-1 min-w-0">
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">线</h1>
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">{displayName || '在线客服'}</h1>
<p className="m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5">
<span
className="inline-block w-1.5 h-1.5 rounded-full"
@@ -375,7 +380,7 @@ const VisitorChat = ({
? '会话已结束'
: agentsOnline
? (agentName ? `${agentName} 为您服务` : '正在为您服务')
: '客服离线 · 可留言'}
: '暂不可即时接待 · 可留言'}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
@@ -405,9 +410,7 @@ const VisitorChat = ({
<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
? '您好!欢迎咨询,请问有什么可以帮您?'
: offlinePrompt}
{agentsOnline ? welcomeMessage : offlinePrompt}
</p>
</div>
</div>