1446 lines
67 KiB
TypeScript
1446 lines
67 KiB
TypeScript
import { useEffect, useState, type ReactNode } from 'react'
|
||
import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm, TimePicker, Upload } from 'antd'
|
||
import {
|
||
LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined,
|
||
PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined,
|
||
MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined,
|
||
EditOutlined, StopOutlined, CheckCircleOutlined, DeleteOutlined,
|
||
ArrowUpOutlined, ArrowDownOutlined, PictureOutlined, TagsOutlined,
|
||
} from '@ant-design/icons'
|
||
import dayjs, { type Dayjs } from 'dayjs'
|
||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||
import {
|
||
createChannel, createCustomerTag, createStaff, deleteCustomerTag, deleteStaff,
|
||
getChannels, getCustomerTags, getStaff, getTenantSettings,
|
||
updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage,
|
||
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment,
|
||
} from '@/services/api'
|
||
import { useAuth, usePermission } from '@/stores/auth'
|
||
import MarkdownEditor from '@/components/common/MarkdownEditor'
|
||
import RichTextEditor from '@/components/common/RichTextEditor'
|
||
|
||
const tagColorPresets: { value: string; label: string; bg: string; color: string }[] = [
|
||
{ value: 'amber', label: '琥珀', bg: '#fef3c7', color: '#92400e' },
|
||
{ value: 'green', label: '绿色', bg: '#f0fdf4', color: '#16a34a' },
|
||
{ value: 'blue', label: '蓝色', bg: '#dbeafe', color: '#2563eb' },
|
||
{ value: 'cyan', label: '青色', bg: '#ecfeff', color: '#0891b2' },
|
||
{ value: 'violet', label: '紫色', bg: '#f3e8ff', color: '#7c3aed' },
|
||
{ value: 'rose', label: '玫红', bg: '#fff1f2', color: '#e11d48' },
|
||
{ value: 'orange', label: '橙色', bg: '#fffbeb', color: '#d97706' },
|
||
{ value: 'slate', label: '灰蓝', bg: '#f1f5f9', color: '#475569' },
|
||
]
|
||
|
||
function tagColorStyle(color: string): { bg: string; color: string } {
|
||
const preset = tagColorPresets.find(p => p.value === color)
|
||
if (preset) return { bg: preset.bg, color: preset.color }
|
||
if (color?.startsWith('#')) return { bg: `${color}22`, color }
|
||
return { bg: '#f1f5f9', color: '#475569' }
|
||
}
|
||
|
||
dayjs.extend(customParseFormat)
|
||
|
||
const typeMeta: Record<string, { icon: ReactNode; label: string; desc: string }> = {
|
||
web: { icon: <GlobalOutlined />, label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' },
|
||
wechat: { icon: <WechatOutlined />, label: '微信公众号', desc: '对接微信公众号客服消息(预留)' },
|
||
app: { icon: <MobileOutlined />, label: 'APP 内嵌', desc: '移动端 App 内嵌客服(预留)' },
|
||
phone: { icon: <PhoneOutlined />, label: '电话客服', desc: '电话进线与回拨(预留)' },
|
||
email: { icon: <MailOutlined />, label: '邮件工单', desc: '邮件自动创建工单(预留)' },
|
||
}
|
||
|
||
const weekDays: { key: string; label: string }[] = [
|
||
{ key: 'monday', label: '周一' },
|
||
{ key: 'tuesday', label: '周二' },
|
||
{ key: 'wednesday', label: '周三' },
|
||
{ key: 'thursday', label: '周四' },
|
||
{ key: 'friday', label: '周五' },
|
||
{ key: 'saturday', label: '周六' },
|
||
{ key: 'sunday', 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 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> = {
|
||
admin: '管理员',
|
||
supervisor: '主管',
|
||
agent: '客服',
|
||
}
|
||
|
||
const statusLabel: Record<string, { text: string; className: string }> = {
|
||
online: { text: '在线', className: 'bg-emerald-50 text-emerald-700' },
|
||
offline: { text: '离线', className: 'bg-neutral-100 text-neutral-600' },
|
||
busy: { text: '忙碌', className: 'bg-amber-50 text-amber-700' },
|
||
disabled: { text: '已禁用', className: 'bg-red-50 text-red-600' },
|
||
}
|
||
|
||
const tabItems: { key: string; label: string; desc: string; icon: ReactNode }[] = [
|
||
{ key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: <SettingOutlined /> },
|
||
{ key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: <ApiOutlined /> },
|
||
{ key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: <UserSwitchOutlined /> },
|
||
{ key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: <TeamOutlined /> },
|
||
{ key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: <TagsOutlined /> },
|
||
{ key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: <MessageOutlined /> },
|
||
{ key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: <ClockCircleOutlined /> },
|
||
{ key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: <BellOutlined /> },
|
||
]
|
||
|
||
const Settings = () => {
|
||
const { user } = useAuth()
|
||
const isAdmin = usePermission('settings.basic')
|
||
const canViewStaff = usePermission('settings.staff')
|
||
|
||
const [activeTab, setActiveTab] = useState('basic')
|
||
const [basicForm] = Form.useForm()
|
||
const [autoReplyForm] = Form.useForm()
|
||
const [workForm] = Form.useForm()
|
||
const [notifyForm] = Form.useForm()
|
||
const [assignForm] = Form.useForm()
|
||
const [staffForm] = 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 [staffList, setStaffList] = useState<StaffUser[]>([])
|
||
const [seatLimit, setSeatLimit] = useState(0)
|
||
const [seatUsed, setSeatUsed] = useState(0)
|
||
const [loadingStaff, setLoadingStaff] = useState(false)
|
||
const [staffModalOpen, setStaffModalOpen] = useState(false)
|
||
const [editingStaff, setEditingStaff] = useState<StaffUser | null>(null)
|
||
const [savingStaff, setSavingStaff] = useState(false)
|
||
const [welcomeSegments, setWelcomeSegments] = useState<WelcomeSegment[]>([
|
||
{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' },
|
||
])
|
||
const [uploadingWelcomeIdx, setUploadingWelcomeIdx] = useState<number | null>(null)
|
||
const [customerTags, setCustomerTags] = useState<CustomerTag[]>([])
|
||
const [loadingTags, setLoadingTags] = useState(false)
|
||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||
const [editingTag, setEditingTag] = useState<CustomerTag | null>(null)
|
||
const [savingTag, setSavingTag] = useState(false)
|
||
const [tagForm] = Form.useForm()
|
||
|
||
const currentTab = tabItems.find(t => t.key === activeTab) || tabItems[0]
|
||
|
||
const loadCustomerTags = async () => {
|
||
setLoadingTags(true)
|
||
try {
|
||
const res = await getCustomerTags()
|
||
setCustomerTags(Array.isArray(res.data) ? res.data : [])
|
||
} catch (e) {
|
||
setCustomerTags([])
|
||
message.error(e instanceof Error ? e.message : '加载标签失败')
|
||
} finally {
|
||
setLoadingTags(false)
|
||
}
|
||
}
|
||
|
||
const loadStaff = async () => {
|
||
setLoadingStaff(true)
|
||
try {
|
||
const res = await getStaff()
|
||
setStaffList(Array.isArray(res.data?.list) ? res.data.list : [])
|
||
setSeatLimit(res.data?.seat_limit ?? 0)
|
||
setSeatUsed(res.data?.seat_used ?? 0)
|
||
} catch (e) {
|
||
setStaffList([])
|
||
message.error(e instanceof Error ? e.message : '加载坐席失败')
|
||
} finally {
|
||
setLoadingStaff(false)
|
||
}
|
||
}
|
||
|
||
const loadChannels = async () => {
|
||
setLoadingChannels(true)
|
||
try {
|
||
const res = await getChannels()
|
||
setChannels(Array.isArray(res.data) ? res.data : [])
|
||
} catch {
|
||
setChannels([])
|
||
message.error('加载渠道失败')
|
||
} finally {
|
||
setLoadingChannels(false)
|
||
}
|
||
}
|
||
|
||
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({
|
||
offline_prompt: data.offline_prompt,
|
||
})
|
||
setWelcomeSegments(normalizeWelcomeFromSettings(data))
|
||
{
|
||
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,
|
||
notify_daily_report: data.notify_daily_report,
|
||
})
|
||
assignForm.setFieldsValue({
|
||
assign_strategy: data.assign_strategy || 'least_load',
|
||
max_active_per_agent: data.max_active_per_agent ?? 0,
|
||
})
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '加载设置失败')
|
||
} finally {
|
||
setLoadingSettings(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (activeTab === 'channels') loadChannels()
|
||
if (['basic', 'autoreply', 'worktime', 'notify', 'assignment'].includes(activeTab)) loadSettings()
|
||
if (activeTab === 'staff' && canViewStaff) loadStaff()
|
||
if (activeTab === 'tags') void loadCustomerTags()
|
||
}, [activeTab])
|
||
|
||
const openCreateStaff = () => {
|
||
setEditingStaff(null)
|
||
staffForm.resetFields()
|
||
staffForm.setFieldsValue({ role: 'agent' })
|
||
setStaffModalOpen(true)
|
||
}
|
||
|
||
const openEditStaff = (s: StaffUser) => {
|
||
setEditingStaff(s)
|
||
staffForm.resetFields()
|
||
staffForm.setFieldsValue({
|
||
nickname: s.nickname,
|
||
role: s.role === 'admin' ? 'admin' : s.role,
|
||
status: s.status,
|
||
password: undefined,
|
||
})
|
||
setStaffModalOpen(true)
|
||
}
|
||
|
||
const handleSaveStaff = async (values: {
|
||
username?: string; password?: string; nickname?: string; role?: string; status?: string
|
||
}) => {
|
||
setSavingStaff(true)
|
||
try {
|
||
if (editingStaff) {
|
||
await updateStaff(editingStaff.id, {
|
||
nickname: values.nickname,
|
||
role: values.role === 'admin' ? 'admin' : values.role,
|
||
status: values.status,
|
||
password: values.password || undefined,
|
||
})
|
||
message.success('坐席已更新')
|
||
} else {
|
||
await createStaff({
|
||
username: values.username!.trim(),
|
||
password: values.password!,
|
||
nickname: values.nickname?.trim(),
|
||
role: values.role || 'agent',
|
||
})
|
||
message.success('坐席已创建')
|
||
}
|
||
setStaffModalOpen(false)
|
||
await loadStaff()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败')
|
||
} finally {
|
||
setSavingStaff(false)
|
||
}
|
||
}
|
||
|
||
const handleDisableStaff = async (s: StaffUser) => {
|
||
try {
|
||
await deleteStaff(s.id)
|
||
message.success('已禁用')
|
||
await loadStaff()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '操作失败')
|
||
}
|
||
}
|
||
|
||
const handleEnableStaff = async (s: StaffUser) => {
|
||
try {
|
||
await updateStaff(s.id, { status: 'offline' })
|
||
message.success('已启用')
|
||
await loadStaff()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '启用失败')
|
||
}
|
||
}
|
||
|
||
const savePartial = async (payload: Parameters<typeof updateTenantSettings>[0], okText = '已保存') => {
|
||
setSaving(true)
|
||
try {
|
||
const res = await updateTenantSettings(payload)
|
||
setSettings(res.data)
|
||
if (payload.welcome_messages || res.data?.welcome_messages) {
|
||
setWelcomeSegments(normalizeWelcomeFromSettings(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
|
||
const text = script.includes('src="/widget.js"')
|
||
? script.replace('src="/widget.js"', `src="${origin}/widget.js"`)
|
||
: script
|
||
await navigator.clipboard.writeText(text)
|
||
message.success('已复制接入代码')
|
||
} catch {
|
||
message.error('复制失败')
|
||
}
|
||
}
|
||
|
||
const toggleChannel = async (ch: Channel, enabled: boolean) => {
|
||
setTogglingId(ch.id)
|
||
try {
|
||
const res = await updateChannel(ch.id, { status: enabled ? 'enabled' : 'disabled' })
|
||
setChannels(prev => prev.map(c => (c.id === ch.id ? res.data : c)))
|
||
message.success(enabled ? '渠道已启用' : '渠道已停用')
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '更新失败')
|
||
} finally {
|
||
setTogglingId(null)
|
||
}
|
||
}
|
||
|
||
const ensureWebChannel = async () => {
|
||
try {
|
||
await createChannel({ type: 'web', name: '网页聊天' })
|
||
message.success('已创建网页渠道')
|
||
await loadChannels()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '创建失败')
|
||
}
|
||
}
|
||
|
||
const addChannelType = async (t: string) => {
|
||
try {
|
||
await createChannel({ type: t, name: typeMeta[t].label })
|
||
message.success('已添加')
|
||
await loadChannels()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '添加失败')
|
||
}
|
||
}
|
||
|
||
const missingTypes = Object.keys(typeMeta).filter(t => !channels.some(c => c.type === t))
|
||
|
||
const panelHeaderAction = activeTab === 'channels' && missingTypes.includes('web') ? (
|
||
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={ensureWebChannel}>
|
||
开通网页渠道
|
||
</Button>
|
||
) : activeTab === 'channels' && missingTypes.length > 0 ? (
|
||
<Button type="primary" size="small" icon={<PlusOutlined />} className="!h-8" onClick={() => addChannelType(missingTypes[0])}>
|
||
添加新渠道
|
||
</Button>
|
||
) : null
|
||
|
||
return (
|
||
<div className="h-full flex min-h-0 overflow-hidden bg-slate-50/60">
|
||
{/* 左侧设置导航 — 顶栏 56px */}
|
||
<aside className="w-[210px] shrink-0 flex flex-col bg-white border-r border-slate-200/80">
|
||
<div
|
||
className="shrink-0 px-4 flex flex-col justify-center border-b border-slate-100"
|
||
style={{ height: 'var(--header-height)' }}
|
||
>
|
||
<div className="text-xs font-bold text-slate-900 leading-tight">系统设置</div>
|
||
<div className="text-[10px] text-slate-400 truncate leading-tight mt-0.5 font-medium">配置与渠道接入</div>
|
||
</div>
|
||
<nav className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||
{tabItems.map(item => {
|
||
const active = activeTab === item.key
|
||
return (
|
||
<button
|
||
key={item.key}
|
||
type="button"
|
||
onClick={() => setActiveTab(item.key)}
|
||
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-xs text-left border-0 cursor-pointer transition-all ${
|
||
active
|
||
? 'bg-blue-50/80 text-blue-600 font-semibold shadow-2xs'
|
||
: 'bg-transparent text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||
}`}
|
||
>
|
||
<span className={`text-sm leading-none ${active ? 'text-blue-600' : 'text-slate-400'}`}>
|
||
{item.icon}
|
||
</span>
|
||
<span className="truncate">{item.label}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</nav>
|
||
</aside>
|
||
|
||
{/* 右侧内容 */}
|
||
<section className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||
<div
|
||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-slate-200/80 bg-white"
|
||
style={{ height: 'var(--header-height)' }}
|
||
>
|
||
<div className="min-w-0">
|
||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">{currentTab.label}</h1>
|
||
<p className="text-[11px] text-slate-400 m-0 leading-tight">{currentTab.desc}</p>
|
||
</div>
|
||
{panelHeaderAction}
|
||
{activeTab === 'staff' && isAdmin && (
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PlusOutlined />}
|
||
className="!h-8 !rounded-lg !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||
disabled={seatLimit > 0 && seatUsed >= seatLimit}
|
||
onClick={openCreateStaff}
|
||
>
|
||
添加坐席
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto px-6 py-5">
|
||
<div className={activeTab === 'staff' ? 'max-w-4xl' : 'max-w-3xl'}>
|
||
<p className="text-xs text-neutral-400 mt-0 mb-5">{currentTab.desc}</p>
|
||
|
||
{activeTab === 'basic' && (
|
||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||
{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 className="!mb-0">
|
||
<Button type="primary" htmlType="submit" loading={saving}>保存设置</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'channels' && (
|
||
<div>
|
||
{loadingChannels ? (
|
||
<div className="py-16 text-center"><Spin /></div>
|
||
) : channels.length === 0 ? (
|
||
<div className="bg-white rounded-xl border border-neutral-200 p-10">
|
||
<Empty description="暂无渠道,请先开通网页渠道">
|
||
<Button type="primary" onClick={ensureWebChannel}>开通网页渠道</Button>
|
||
</Empty>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-3">
|
||
{channels.map(ch => {
|
||
const meta = typeMeta[ch.type] || { icon: <LinkOutlined />, label: ch.name || ch.type, desc: '' }
|
||
const enabled = ch.status === 'enabled'
|
||
return (
|
||
<div
|
||
key={ch.id}
|
||
className="bg-white rounded-xl border border-neutral-200 shadow-sm p-4"
|
||
>
|
||
<div className="flex items-start gap-4">
|
||
<div className="w-10 h-10 rounded-lg bg-[#eff6ff] text-[#2563eb] flex items-center justify-center text-lg shrink-0">
|
||
{meta.icon}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center justify-between gap-3 mb-1">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<h3 className="text-sm font-medium text-neutral-900 m-0 truncate">
|
||
{ch.name || meta.label}
|
||
</h3>
|
||
<span
|
||
className={`inline-flex items-center rounded px-2 py-0.5 text-[11px] font-medium whitespace-nowrap ${
|
||
enabled ? 'bg-[#f0fdf4] text-[#16a34a]' : 'bg-neutral-100 text-neutral-500'
|
||
}`}
|
||
>
|
||
{enabled ? '已启用' : '未启用'}
|
||
</span>
|
||
</div>
|
||
<Switch
|
||
checked={enabled}
|
||
size="small"
|
||
loading={togglingId === ch.id}
|
||
onChange={v => toggleChannel(ch, v)}
|
||
/>
|
||
</div>
|
||
{meta.desc && (
|
||
<p className="text-xs text-neutral-400 m-0 mb-3">{meta.desc}</p>
|
||
)}
|
||
{ch.channel_key && (
|
||
<div className="flex items-center gap-2 text-xs mb-2">
|
||
<span className="text-neutral-400">渠道 ID</span>
|
||
<code className="bg-neutral-100 text-neutral-600 px-2 py-0.5 rounded">
|
||
{ch.channel_key}
|
||
</code>
|
||
</div>
|
||
)}
|
||
{ch.type === 'web' && ch.script_code && (
|
||
<div className="mt-2">
|
||
<div className="text-xs text-neutral-400 mb-1">接入代码</div>
|
||
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-2.5 flex items-start justify-between gap-2">
|
||
<code className="text-[11px] text-neutral-600 break-all leading-relaxed">
|
||
{ch.script_code.replace(
|
||
'src="/widget.js"',
|
||
`src="${window.location.origin}/widget.js"`,
|
||
)}
|
||
</code>
|
||
<button
|
||
type="button"
|
||
className="shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||
title="复制"
|
||
onClick={() => copyScript(ch.script_code)}
|
||
>
|
||
<CopyOutlined />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{ch.type !== 'web' && (
|
||
<div className="text-xs text-neutral-400 mt-1">
|
||
本期仅网页渠道可完整接入,其他类型预留开关。
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{missingTypes.length > 0 && channels.length > 0 && (
|
||
<div className="mt-5">
|
||
<div className="text-xs text-neutral-400 mb-2">可添加渠道类型</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{missingTypes.map(t => (
|
||
<Button key={t} size="small" icon={<PlusOutlined />} onClick={() => addChannelType(t)}>
|
||
{typeMeta[t].label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'assignment' && (
|
||
<div className="space-y-4">
|
||
{loadingSettings ? (
|
||
<div className="py-16 text-center"><Spin /></div>
|
||
) : (
|
||
<>
|
||
<div className="bg-white rounded-xl border border-[#dbeafe] shadow-sm p-5">
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-9 h-9 rounded-lg bg-[#eff6ff] text-[#2563eb] flex items-center justify-center shrink-0">
|
||
<TeamOutlined />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-medium text-neutral-800">自动分配规则</div>
|
||
<div className="text-xs text-neutral-500 mt-1.5 leading-relaxed">
|
||
工作时间内,访客进线会按下列策略分配给<strong className="font-medium text-neutral-700">在线一线客服</strong>;
|
||
非工作时间或无可接坐席时进入排队/留言。人工领取与转接不受此策略限制。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
|
||
<Form
|
||
form={assignForm}
|
||
layout="vertical"
|
||
disabled={!isAdmin}
|
||
onFinish={async (values: {
|
||
assign_strategy: 'least_load' | 'round_robin'
|
||
max_active_per_agent: number
|
||
}) => {
|
||
await savePartial({
|
||
assign_strategy: values.assign_strategy,
|
||
max_active_per_agent: Number(values.max_active_per_agent) || 0,
|
||
}, '分配规则已保存')
|
||
}}
|
||
>
|
||
<Form.Item
|
||
name="assign_strategy"
|
||
label="分配策略"
|
||
rules={[{ required: true, message: '请选择策略' }]}
|
||
extra="负载最低:优先进行中会话更少的坐席;轮询:在可接坐席中依次分配,更均匀。"
|
||
>
|
||
<Select
|
||
options={[
|
||
{ value: 'least_load', label: '负载最低(推荐)' },
|
||
{ value: 'round_robin', label: '轮询分配' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="max_active_per_agent"
|
||
label="每位坐席最大进行中会话数"
|
||
rules={[
|
||
{ required: true, message: '请填写上限' },
|
||
{
|
||
validator: async (_, v) => {
|
||
const n = Number(v)
|
||
if (!Number.isFinite(n) || n < 0 || n > 200) {
|
||
throw new Error('请填写 0–200 的整数,0 表示不限制')
|
||
}
|
||
},
|
||
},
|
||
]}
|
||
extra="达到上限的坐席不再自动分入新会话;全部坐席已满时会话保持排队。0 = 不限制。"
|
||
>
|
||
<Input type="number" min={0} max={200} placeholder="0 表示不限制" />
|
||
</Form.Item>
|
||
{isAdmin ? (
|
||
<Button type="primary" htmlType="submit" loading={saving}>
|
||
保存分配规则
|
||
</Button>
|
||
) : (
|
||
<p className="text-xs text-neutral-400 m-0">仅租户管理员可修改分配规则</p>
|
||
)}
|
||
</Form>
|
||
</div>
|
||
|
||
<div className="bg-neutral-50 rounded-xl border border-neutral-200 p-4 text-xs text-neutral-500 leading-relaxed space-y-1">
|
||
<div className="font-medium text-neutral-700 mb-1">说明</div>
|
||
<p className="m-0">· 候选范围:角色为客服、状态为「在线」的账号(忙碌/离线不参与自动分配)。</p>
|
||
<p className="m-0">· 技能组 / 渠道专属坐席将在后续版本支持。</p>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'staff' && (
|
||
<div>
|
||
{!canViewStaff ? (
|
||
<div className="bg-white rounded-xl border border-neutral-200 p-8 text-center text-sm text-neutral-500">
|
||
仅管理员或主管可查看坐席列表
|
||
</div>
|
||
) : loadingStaff ? (
|
||
<div className="py-16 text-center"><Spin /></div>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center gap-4 mb-4 flex-wrap">
|
||
<div className="rounded-xl bg-white border border-neutral-200 px-4 py-3 min-w-[140px]">
|
||
<div className="text-[11px] text-neutral-400 mb-0.5">坐席占用</div>
|
||
<div className="text-lg font-semibold text-neutral-900 tabular-nums">
|
||
{seatUsed}
|
||
<span className="text-sm font-normal text-neutral-400"> / {seatLimit}</span>
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-neutral-400 flex-1 min-w-[200px]">
|
||
启用中的管理员、主管、客服各占 1 个坐席。禁用账号释放配额。角色:客服接待会话;主管可看统计与客户;管理员可改系统设置。
|
||
</div>
|
||
</div>
|
||
|
||
{staffList.length === 0 ? (
|
||
<Empty description="暂无坐席账号" className="bg-white rounded-xl border border-neutral-200 py-12" />
|
||
) : (
|
||
<div className="rounded-xl border border-neutral-200 bg-white overflow-hidden">
|
||
<table className="w-full border-collapse min-w-[640px]">
|
||
<thead>
|
||
<tr className="bg-neutral-50 text-[11px] font-semibold text-neutral-500">
|
||
<th className="text-left px-4 py-2.5 border-b border-neutral-200">账号</th>
|
||
<th className="text-left px-4 py-2.5 border-b border-neutral-200">昵称</th>
|
||
<th className="text-left px-4 py-2.5 border-b border-neutral-200">角色</th>
|
||
<th className="text-left px-4 py-2.5 border-b border-neutral-200">状态</th>
|
||
<th className="text-left px-4 py-2.5 border-b border-neutral-200">创建时间</th>
|
||
{isAdmin && (
|
||
<th className="text-right px-4 py-2.5 border-b border-neutral-200">操作</th>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{staffList.map(s => {
|
||
const st = statusLabel[s.status] || statusLabel.offline
|
||
return (
|
||
<tr key={s.id} className="hover:bg-neutral-50 border-t border-neutral-100">
|
||
<td className="px-4 py-3 text-sm font-medium text-neutral-800">{s.username}</td>
|
||
<td className="px-4 py-3 text-sm text-neutral-700">{s.nickname || '—'}</td>
|
||
<td className="px-4 py-3">
|
||
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-100 text-neutral-600">
|
||
{roleLabel[s.role] || s.role}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${st.className}`}>
|
||
{st.text}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-xs text-neutral-400 whitespace-nowrap">
|
||
{s.created_at ? s.created_at.slice(0, 16) : '—'}
|
||
</td>
|
||
{isAdmin && (
|
||
<td className="px-4 py-3 text-right" onClick={e => e.stopPropagation()}>
|
||
<div className="inline-flex items-center gap-1">
|
||
<button
|
||
type="button"
|
||
title="编辑"
|
||
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||
onClick={() => openEditStaff(s)}
|
||
>
|
||
<EditOutlined className="text-xs" />
|
||
</button>
|
||
{s.status === 'disabled' ? (
|
||
<button
|
||
type="button"
|
||
title="启用"
|
||
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-emerald-600 hover:bg-emerald-50 border-0 bg-transparent cursor-pointer"
|
||
onClick={() => handleEnableStaff(s)}
|
||
>
|
||
<CheckCircleOutlined className="text-xs" />
|
||
</button>
|
||
) : (
|
||
<Popconfirm
|
||
title="确认禁用该账号?"
|
||
description="禁用后无法登录,并释放坐席"
|
||
onConfirm={() => handleDisableStaff(s)}
|
||
disabled={s.id === user?.user_id}
|
||
>
|
||
<button
|
||
type="button"
|
||
title="禁用"
|
||
disabled={s.id === user?.user_id}
|
||
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer disabled:opacity-30"
|
||
>
|
||
<StopOutlined className="text-xs" />
|
||
</button>
|
||
</Popconfirm>
|
||
)}
|
||
</div>
|
||
</td>
|
||
)}
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'autoreply' && (
|
||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||
{loadingSettings && !settings ? (
|
||
<div className="py-10 text-center"><Spin /></div>
|
||
) : (
|
||
<Form
|
||
form={autoReplyForm}
|
||
layout="vertical"
|
||
onFinish={async values => {
|
||
if (welcomeSegments.length === 0) {
|
||
message.error('至少保留一段欢迎语')
|
||
return
|
||
}
|
||
for (let i = 0; i < welcomeSegments.length; i++) {
|
||
const seg = welcomeSegments[i]
|
||
if (seg.type === 'text') {
|
||
const plain = seg.content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim()
|
||
if (!plain) {
|
||
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,
|
||
}, '自动回复已保存')
|
||
}}
|
||
>
|
||
<div className="mb-5">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<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} 段。
|
||
选中文字可设置<strong className="font-medium text-neutral-600">颜色、字号、加粗</strong>等(仅欢迎语;正常聊天仍为 Markdown)。
|
||
</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' ? (
|
||
<RichTextEditor
|
||
value={seg.content}
|
||
placeholder="输入欢迎语,选中文字后设置颜色、字号…"
|
||
onChange={v => {
|
||
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 }]}
|
||
extra="点工具栏设置加粗、列表、链接、表情;可点「预览」查看效果"
|
||
>
|
||
<MarkdownEditor
|
||
rows={3}
|
||
maxLength={500}
|
||
showCount
|
||
placeholder="无客服在线时展示…"
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item className="!mb-0">
|
||
<Button type="primary" htmlType="submit" loading={saving}>保存</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'worktime' && (
|
||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||
{loadingSettings && !settings ? (
|
||
<div className="py-10 text-center"><Spin /></div>
|
||
) : (
|
||
<Form
|
||
form={workForm}
|
||
layout="vertical"
|
||
onFinish={values => {
|
||
const work_hours: WorkHours = {}
|
||
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-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 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 shrink-0">{day.label}</span>
|
||
<Form.Item name={`${day.key}_mode`} className="!mb-0" initialValue="rest">
|
||
<Select
|
||
options={workModeOptions}
|
||
className="!w-[120px]"
|
||
size="small"
|
||
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>
|
||
<Form.Item name="worktime_prompt" label="非工作时间提示" rules={[{ max: 500 }]}>
|
||
<Input placeholder="当前为非工作时间,我们会在工作时段尽快回复您" maxLength={500} />
|
||
</Form.Item>
|
||
<Form.Item className="!mb-0">
|
||
<Button type="primary" htmlType="submit" loading={saving}>保存工作时间</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'notify' && (
|
||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||
{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-5">
|
||
{[
|
||
{ name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒;浏览器通知与声音可在工作台铃铛中配置' },
|
||
{ name: 'notify_offline_leave', title: '离线留言通知', desc: '访客提交离线留言时提醒' },
|
||
{ name: 'notify_daily_report', title: '日报推送', desc: '每日服务数据摘要' },
|
||
].map(item => (
|
||
<div
|
||
key={item.name}
|
||
className="flex items-center justify-between p-3.5 border border-neutral-100 rounded-lg"
|
||
>
|
||
<div className="min-w-0 pr-3">
|
||
<div className="text-sm font-medium text-neutral-800">{item.title}</div>
|
||
<div className="text-xs text-neutral-400 mt-0.5">{item.desc}</div>
|
||
</div>
|
||
<Form.Item name={item.name} valuePropName="checked" className="!mb-0">
|
||
<Switch />
|
||
</Form.Item>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<Form.Item className="!mb-0">
|
||
<Button type="primary" htmlType="submit" loading={saving}>保存通知设置</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'tags' && (
|
||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-6">
|
||
<div className="flex items-start justify-between gap-3 mb-4">
|
||
<div>
|
||
<p className="text-sm text-neutral-500 m-0">
|
||
在此创建标签后,客户管理与工作台中员工只能从列表选择,不能随意输入新标签。
|
||
</p>
|
||
</div>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PlusOutlined />}
|
||
onClick={() => {
|
||
setEditingTag(null)
|
||
tagForm.resetFields()
|
||
tagForm.setFieldsValue({ color: 'blue' })
|
||
setTagModalOpen(true)
|
||
}}
|
||
>
|
||
新建标签
|
||
</Button>
|
||
</div>
|
||
{loadingTags ? (
|
||
<div className="py-10 text-center"><Spin /></div>
|
||
) : customerTags.length === 0 ? (
|
||
<Empty description="暂无标签,请先创建" />
|
||
) : (
|
||
<div className="space-y-2">
|
||
{customerTags.map(tag => {
|
||
const style = tagColorStyle(tag.color)
|
||
return (
|
||
<div
|
||
key={tag.id}
|
||
className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg border border-neutral-100"
|
||
>
|
||
<span
|
||
className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium"
|
||
style={{ backgroundColor: style.bg, color: style.color }}
|
||
>
|
||
{tag.name}
|
||
</span>
|
||
<div className="flex items-center gap-1">
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<EditOutlined />}
|
||
onClick={() => {
|
||
setEditingTag(tag)
|
||
tagForm.setFieldsValue({ name: tag.name, color: tag.color || 'slate' })
|
||
setTagModalOpen(true)
|
||
}}
|
||
/>
|
||
<Popconfirm
|
||
title="删除该标签?"
|
||
description="将从标签库移除,并同步去掉客户上的此标签"
|
||
onConfirm={async () => {
|
||
try {
|
||
await deleteCustomerTag(tag.id)
|
||
message.success('已删除')
|
||
await loadCustomerTags()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '删除失败')
|
||
}
|
||
}}
|
||
>
|
||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||
</Popconfirm>
|
||
</div>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<Modal
|
||
title={editingTag ? '编辑客户标签' : '新建客户标签'}
|
||
open={tagModalOpen}
|
||
onCancel={() => setTagModalOpen(false)}
|
||
onOk={() => tagForm.submit()}
|
||
confirmLoading={savingTag}
|
||
destroyOnHidden
|
||
okText="保存"
|
||
width={400}
|
||
>
|
||
<Form
|
||
form={tagForm}
|
||
layout="vertical"
|
||
className="mt-2"
|
||
onFinish={async values => {
|
||
setSavingTag(true)
|
||
try {
|
||
const payload = {
|
||
name: String(values.name || '').trim(),
|
||
color: values.color || 'slate',
|
||
}
|
||
if (editingTag) {
|
||
await updateCustomerTag(editingTag.id, payload)
|
||
message.success('标签已更新')
|
||
} else {
|
||
await createCustomerTag(payload)
|
||
message.success('标签已创建')
|
||
}
|
||
setTagModalOpen(false)
|
||
await loadCustomerTags()
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败')
|
||
} finally {
|
||
setSavingTag(false)
|
||
}
|
||
}}
|
||
>
|
||
<Form.Item
|
||
name="name"
|
||
label="标签名称"
|
||
rules={[{ required: true, message: '请输入名称' }, { max: 30, message: '最多 30 字' }]}
|
||
>
|
||
<Input maxLength={30} placeholder="如:VIP客户、高意向" />
|
||
</Form.Item>
|
||
<Form.Item name="color" label="颜色" rules={[{ required: true }]}>
|
||
<Select
|
||
options={tagColorPresets.map(p => ({
|
||
value: p.value,
|
||
label: (
|
||
<span className="inline-flex items-center gap-2">
|
||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: p.color }} />
|
||
{p.label}
|
||
</span>
|
||
),
|
||
}))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title={editingStaff ? '编辑坐席' : '添加坐席'}
|
||
open={staffModalOpen}
|
||
onCancel={() => setStaffModalOpen(false)}
|
||
onOk={() => staffForm.submit()}
|
||
confirmLoading={savingStaff}
|
||
destroyOnClose
|
||
okText="保存"
|
||
width={440}
|
||
>
|
||
<Form form={staffForm} layout="vertical" onFinish={handleSaveStaff} className="mt-2" requiredMark={false}>
|
||
{!editingStaff && (
|
||
<>
|
||
<Form.Item
|
||
name="username"
|
||
label="登录用户名"
|
||
rules={[{ required: true, message: '请输入用户名' }, { min: 3, max: 30, message: '3-30 个字符' }]}
|
||
>
|
||
<Input placeholder="英文/数字,全局唯一" autoComplete="off" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="password"
|
||
label="初始密码"
|
||
rules={[{ required: true, message: '请输入密码' }, { min: 6, max: 64, message: '至少 6 位' }]}
|
||
>
|
||
<Input.Password placeholder="至少 6 位" autoComplete="new-password" />
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
<Form.Item name="nickname" label="显示昵称" rules={[{ max: 50 }]}>
|
||
<Input placeholder="工作台显示名称" />
|
||
</Form.Item>
|
||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||
<Select
|
||
options={
|
||
editingStaff?.role === 'admin'
|
||
? [
|
||
{ value: 'admin', label: '管理员' },
|
||
{ value: 'supervisor', label: '主管' },
|
||
{ value: 'agent', label: '客服' },
|
||
]
|
||
: [
|
||
{ value: 'agent', label: '客服' },
|
||
{ value: 'supervisor', label: '主管' },
|
||
...(isAdmin ? [{ value: 'admin', label: '管理员' }] : []),
|
||
]
|
||
}
|
||
/>
|
||
</Form.Item>
|
||
{editingStaff && (
|
||
<>
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
options={[
|
||
{ value: 'online', label: '在线' },
|
||
{ value: 'offline', label: '离线' },
|
||
{ value: 'busy', label: '忙碌' },
|
||
{ value: 'disabled', label: '禁用' },
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="password" label="重置密码" extra="留空则不修改">
|
||
<Input.Password placeholder="可选,至少 6 位" autoComplete="new-password" />
|
||
</Form.Item>
|
||
</>
|
||
)}
|
||
</Form>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default Settings
|