工作时间支持自定义时段

设置页每日可选休息/全天/自定义时间范围(TimePicker),后端校验并规范化 HH:MM-HH:MM,补充跨午夜判定测试。
This commit is contained in:
yml2213
2026-07-17 21:39:26 +08:00
parent ff90d742ad
commit 7319799ec4
3 changed files with 360 additions and 53 deletions
+176 -22
View File
@@ -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>