工作时间支持自定义时段
设置页每日可选休息/全天/自定义时间范围(TimePicker),后端校验并规范化 HH:MM-HH:MM,补充跨午夜判定测试。
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 应在跨午夜时段")
|
||||
}
|
||||
}
|
||||
@@ -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<string, { icon: ReactNode; label: string; desc: string }> = {
|
||||
web: { icon: <GlobalOutlined />, label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' },
|
||||
wechat: { icon: <WechatOutlined />, 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<string, string> = {
|
||||
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<string, unknown> = {
|
||||
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,
|
||||
}, '工作时间已保存')
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-neutral-500 mb-4">
|
||||
非工作时间访客将看到下方提示并可留言;清空某天时段表示休息。
|
||||
<p className="text-sm text-neutral-500 mb-3">
|
||||
每天可设为休息、全天或自定义时段(支持跨午夜,如 22:00-06:00)。
|
||||
非工作时间访客将看到下方提示并可留言。
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const patch: Record<string, unknown> = {}
|
||||
weekDays.forEach((d, i) => {
|
||||
if (i < 5) {
|
||||
patch[`${d.key}_mode`] = 'custom'
|
||||
patch[`${d.key}_range`] = defaultCustomRange()
|
||||
} else {
|
||||
patch[`${d.key}_mode`] = 'rest'
|
||||
patch[`${d.key}_range`] = undefined
|
||||
}
|
||||
})
|
||||
workForm.setFieldsValue(patch)
|
||||
}}
|
||||
>
|
||||
工作日 09:00-18:00
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const patch: Record<string, unknown> = {}
|
||||
weekDays.forEach(d => {
|
||||
patch[`${d.key}_mode`] = 'allday'
|
||||
patch[`${d.key}_range`] = undefined
|
||||
})
|
||||
workForm.setFieldsValue(patch)
|
||||
}}
|
||||
>
|
||||
全部全天
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const patch: Record<string, unknown> = {}
|
||||
weekDays.forEach(d => {
|
||||
patch[`${d.key}_mode`] = 'rest'
|
||||
patch[`${d.key}_range`] = undefined
|
||||
})
|
||||
workForm.setFieldsValue(patch)
|
||||
}}
|
||||
>
|
||||
全部休息
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-100 overflow-hidden mb-4">
|
||||
{weekDays.map((day, i) => (
|
||||
<div
|
||||
key={day.key}
|
||||
className={`flex items-center justify-between px-4 py-2.5 ${
|
||||
className={`flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 ${
|
||||
i < weekDays.length - 1 ? '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">
|
||||
<span className="text-sm text-neutral-700 w-12 shrink-0">{day.label}</span>
|
||||
<Form.Item name={`${day.key}_mode`} className="!mb-0" initialValue="rest">
|
||||
<Select
|
||||
placeholder="休息"
|
||||
className="!w-44"
|
||||
options={workModeOptions}
|
||||
className="!w-[120px]"
|
||||
size="small"
|
||||
allowClear
|
||||
options={hourOptions}
|
||||
onChange={(mode: WorkMode) => {
|
||||
if (mode === 'custom' && !workForm.getFieldValue(`${day.key}_range`)) {
|
||||
workForm.setFieldValue(`${day.key}_range`, defaultCustomRange())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, cur) =>
|
||||
prev[`${day.key}_mode`] !== cur[`${day.key}_mode`]
|
||||
}
|
||||
>
|
||||
{() =>
|
||||
workForm.getFieldValue(`${day.key}_mode`) === 'custom' ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Form.Item
|
||||
name={`${day.key}_range`}
|
||||
className="!mb-0"
|
||||
rules={[{ required: true, message: '请选择时间范围' }]}
|
||||
>
|
||||
<TimePicker.RangePicker
|
||||
format="HH:mm"
|
||||
size="small"
|
||||
allowClear={false}
|
||||
order={false}
|
||||
needConfirm={false}
|
||||
placeholder={['开始', '结束']}
|
||||
className="!w-[200px]"
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{workRangePresets.map(p => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="link"
|
||||
size="small"
|
||||
className="!px-1.5 !h-6 text-xs text-neutral-500"
|
||||
onClick={() => {
|
||||
workForm.setFieldsValue({
|
||||
[`${day.key}_mode`]: 'custom',
|
||||
[`${day.key}_range`]: [
|
||||
dayjs(p.start, 'HH:mm'),
|
||||
dayjs(p.end, 'HH:mm'),
|
||||
],
|
||||
})
|
||||
}}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user