diff --git a/server/internal/handler/settings.go b/server/internal/handler/settings.go index e7046cd..d61731a 100644 --- a/server/internal/handler/settings.go +++ b/server/internal/handler/settings.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "fmt" "net/http" "strings" "time" @@ -81,8 +82,61 @@ func parseWorkHours(raw string) map[string]string { return result } +// normalizeWorkHourSlot 规范化单日工作时间配置。 +// 允许:空/休息(→ 空)、全天、HH:MM-HH:MM(支持跨午夜,如 22:00-06:00)。 +func normalizeWorkHourSlot(val string) (string, error) { + val = strings.TrimSpace(val) + if val == "" || val == "休息" { + return "", nil + } + if val == "全天" { + return "全天", nil + } + // 仅允许一个区间:HH:MM-HH:MM + parts := strings.Split(val, "-") + if len(parts) != 2 { + return "", fmt.Errorf("工作时间格式无效,请使用 09:00-18:00 / 全天 / 空") + } + startS := strings.TrimSpace(parts[0]) + endS := strings.TrimSpace(parts[1]) + start, err1 := time.Parse("15:04", startS) + end, err2 := time.Parse("15:04", endS) + if err1 != nil || err2 != nil { + return "", fmt.Errorf("工作时间格式无效,请使用 09:00-18:00(24 小时制)") + } + return start.Format("15:04") + "-" + end.Format("15:04"), nil +} + +// slotCoversLocal 判断 slot(全天 / HH:MM-HH:MM)是否覆盖 local 时刻。 +func slotCoversLocal(slot string, local time.Time, loc *time.Location) bool { + slot = strings.TrimSpace(slot) + if slot == "" || slot == "休息" { + return false + } + if slot == "全天" { + return true + } + parts := strings.Split(slot, "-") + if len(parts) != 2 { + // 非法配置 fail-open,避免因脏数据整站拒接 + 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) +} + // isWithinWorkHours 根据租户工作时间配置判断当前是否在工作时段。 -// 未配置或某日为空视为休息;时段格式 HH:MM-HH:MM。 +// 未配置或某日为空视为休息;时段格式 HH:MM-HH:MM(可跨午夜)。 func isWithinWorkHours(setting *model.TenantSetting, now time.Time) bool { if setting == nil { return true @@ -110,30 +164,7 @@ func isWithinWorkHours(setting *model.TenantSetting, now time.Time) bool { 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) + return slotCoversLocal(hours[key], local, loc) } func settingsResponse(setting *model.TenantSetting) gin.H { @@ -271,13 +302,12 @@ func (h *SettingsHandler) Update(c *gin.Context) { 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 + slot, err := normalizeWorkHourSlot(req.WorkHours[key]) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return } - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "工作时间格式无效,请使用 09:00-18:00 / 全天 / 空"}) - return + normalized[key] = slot } b, err := json.Marshal(normalized) if err != nil { diff --git a/server/internal/handler/settings_workhours_test.go b/server/internal/handler/settings_workhours_test.go new file mode 100644 index 0000000..1b16331 --- /dev/null +++ b/server/internal/handler/settings_workhours_test.go @@ -0,0 +1,123 @@ +package handler + +import ( + "testing" + "time" + + "kefu-cloud/server/internal/model" +) + +func TestNormalizeWorkHourSlot(t *testing.T) { + cases := []struct { + in string + want string + wantErr bool + }{ + {"", "", false}, + {" 休息 ", "", false}, + {"全天", "全天", false}, + {"09:00-18:00", "09:00-18:00", false}, + {" 9:30 - 18:05 ", "09:30-18:05", false}, + {"22:00-06:00", "22:00-06:00", false}, + {"全天营业", "", true}, + {"09:00", "", true}, + {"25:00-26:00", "", true}, + {"09:00-18:00-extra", "", true}, + } + for _, tc := range cases { + got, err := normalizeWorkHourSlot(tc.in) + if tc.wantErr { + if err == nil { + t.Fatalf("normalizeWorkHourSlot(%q) 期望错误,got=%q", tc.in, got) + } + continue + } + if err != nil { + t.Fatalf("normalizeWorkHourSlot(%q) 意外错误: %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("normalizeWorkHourSlot(%q)=%q, want %q", tc.in, got, tc.want) + } + } +} + +func TestSlotCoversLocal(t *testing.T) { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatal(err) + } + // 2026-03-16 周一 + mk := func(h, m int) time.Time { + return time.Date(2026, 3, 16, h, m, 0, 0, loc) + } + + if !slotCoversLocal("全天", mk(3, 0), loc) { + t.Fatal("全天应覆盖任意时刻") + } + if slotCoversLocal("", mk(10, 0), loc) { + t.Fatal("空应视为休息") + } + if slotCoversLocal("休息", mk(10, 0), loc) { + t.Fatal("休息应不覆盖") + } + // 09:00-18:00 半开区间 [start, end) + if !slotCoversLocal("09:00-18:00", mk(9, 0), loc) { + t.Fatal("09:00 应在工作时间内") + } + if !slotCoversLocal("09:00-18:00", mk(17, 59), loc) { + t.Fatal("17:59 应在工作时间内") + } + if slotCoversLocal("09:00-18:00", mk(18, 0), loc) { + t.Fatal("18:00 不应在工作时间内(半开)") + } + if slotCoversLocal("09:00-18:00", mk(8, 59), loc) { + t.Fatal("08:59 不应在工作时间内") + } + // 跨午夜 22:00-06:00 + if !slotCoversLocal("22:00-06:00", mk(23, 0), loc) { + t.Fatal("23:00 应在跨午夜时段内") + } + if !slotCoversLocal("22:00-06:00", mk(5, 0), loc) { + t.Fatal("05:00 应在跨午夜时段内") + } + if slotCoversLocal("22:00-06:00", mk(12, 0), loc) { + t.Fatal("12:00 不应在跨午夜时段内") + } +} + +func TestIsWithinWorkHoursCustomRange(t *testing.T) { + setting := &model.TenantSetting{ + Timezone: "Asia/Shanghai", + WorkHoursJSON: `{ + "monday":"09:30-12:00", + "tuesday":"全天", + "wednesday":"", + "thursday":"22:00-06:00", + "friday":"全天", + "saturday":"休息", + "sunday":"" + }`, + } + loc, _ := time.LoadLocation("Asia/Shanghai") + + // 周一 10:00 → 在 09:30-12:00 + mon := time.Date(2026, 3, 16, 10, 0, 0, 0, loc) + if !isWithinWorkHours(setting, mon) { + t.Fatal("周一 10:00 应在自定义时段内") + } + // 周一 14:00 → 不在 + monPM := time.Date(2026, 3, 16, 14, 0, 0, 0, loc) + if isWithinWorkHours(setting, monPM) { + t.Fatal("周一 14:00 不应在工作时间") + } + // 周三休息 + wed := time.Date(2026, 3, 18, 10, 0, 0, 0, loc) + if isWithinWorkHours(setting, wed) { + t.Fatal("周三应休息") + } + // 周四跨午夜 23:00 + thu := time.Date(2026, 3, 19, 23, 0, 0, 0, loc) + if !isWithinWorkHours(setting, thu) { + t.Fatal("周四 23:00 应在跨午夜时段") + } +} diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index af2cab7..b1ffe00 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -1,11 +1,13 @@ import { useEffect, useState, type ReactNode } from 'react' -import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm } from 'antd' +import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm, TimePicker } from 'antd' import { LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined, MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined, EditOutlined, StopOutlined, CheckCircleOutlined, } 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, @@ -13,6 +15,8 @@ import { } from '@/services/api' import { useAuth } from '@/stores/auth' +dayjs.extend(customParseFormat) + const typeMeta: Record = { web: { icon: , label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' }, wechat: { icon: , label: '微信公众号', desc: '对接微信公众号客服消息(预留)' }, @@ -31,13 +35,51 @@ const weekDays: { key: string; label: string }[] = [ { 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: '全天' }, +/** 工作日模式:休息 / 全天 / 自定义时段 */ +type WorkMode = 'rest' | 'allday' | 'custom' + +const workModeOptions: { value: WorkMode; label: string }[] = [ + { value: 'rest', label: '休息' }, + { value: 'allday', label: '全天' }, + { value: 'custom', label: '自定义时段' }, ] +/** 快捷时段预设(选中后填入自定义时间范围) */ +const workRangePresets: { label: string; start: string; end: string }[] = [ + { label: '09:00-18:00', start: '09:00', end: '18:00' }, + { label: '08:00-17:00', start: '08:00', end: '17:00' }, + { label: '09:00-12:00', start: '09:00', end: '12:00' }, + { label: '13:00-18:00', start: '13:00', end: '18:00' }, + { label: '10:00-22:00', start: '10:00', end: '22:00' }, +] + +function parseWorkSlot(slot?: string): { mode: WorkMode; range?: [Dayjs, Dayjs] } { + const s = (slot || '').trim() + if (!s || s === '休息') return { mode: 'rest' } + if (s === '全天') return { mode: 'allday' } + const m = s.match(/^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/) + if (m) { + const start = dayjs(m[1], 'HH:mm') + const end = dayjs(m[2], 'HH:mm') + if (start.isValid() && end.isValid()) { + return { mode: 'custom', range: [start, end] } + } + } + return { mode: 'rest' } +} + +function encodeWorkSlot(mode: WorkMode | undefined, range?: [Dayjs, Dayjs] | null): string { + if (mode === 'allday') return '全天' + if (mode === 'custom' && range?.[0]?.isValid() && range?.[1]?.isValid()) { + return `${range[0].format('HH:mm')}-${range[1].format('HH:mm')}` + } + return '' +} + +function defaultCustomRange(): [Dayjs, Dayjs] { + return [dayjs('09:00', 'HH:mm'), dayjs('18:00', 'HH:mm')] +} + const roleLabel: Record = { admin: '管理员', supervisor: '主管', @@ -134,10 +176,17 @@ const Settings = () => { 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])), - }) + { + const workFields: Record = { + worktime_prompt: data.worktime_prompt, + } + weekDays.forEach(d => { + const parsed = parseWorkSlot(data.work_hours?.[d.key]) + workFields[`${d.key}_mode`] = parsed.mode + workFields[`${d.key}_range`] = parsed.range + }) + workForm.setFieldsValue(workFields) + } notifyForm.setFieldsValue({ notify_new_session: data.notify_new_session, notify_offline_leave: data.notify_offline_leave, @@ -744,36 +793,141 @@ const Settings = () => { layout="vertical" onFinish={values => { const work_hours: WorkHours = {} - weekDays.forEach(d => { - work_hours[d.key] = values[d.key] || '' - }) + for (const d of weekDays) { + const mode = values[`${d.key}_mode`] as WorkMode | undefined + const range = values[`${d.key}_range`] as [Dayjs, Dayjs] | null | undefined + if (mode === 'custom' && (!range?.[0] || !range?.[1])) { + message.error(`${d.label} 请选择自定义时间范围`) + return + } + work_hours[d.key] = encodeWorkSlot(mode, range) + } return savePartial({ work_hours, worktime_prompt: values.worktime_prompt, }, '工作时间已保存') }} > -

- 非工作时间访客将看到下方提示并可留言;清空某天时段表示休息。 +

+ 每天可设为休息、全天或自定义时段(支持跨午夜,如 22:00-06:00)。 + 非工作时间访客将看到下方提示并可留言。

+
+ + + +
{weekDays.map((day, i) => (
- {day.label} - + {day.label} +