Files
kefu_cloud/web/src/pages/agent/Settings.tsx
T
2026-08-31 16:25:26 +08:00

1446 lines
67 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/&nbsp;/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