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 = { web: { icon: , label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' }, wechat: { icon: , label: '微信公众号', desc: '对接微信公众号客服消息(预留)' }, app: { icon: , label: 'APP 内嵌', desc: '移动端 App 内嵌客服(预留)' }, phone: { icon: , label: '电话客服', desc: '电话进线与回拨(预留)' }, email: { icon: , 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 = { admin: '管理员', supervisor: '主管', agent: '客服', } const statusLabel: Record = { 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: }, { key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: }, { key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: }, { key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: }, { key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: }, { key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: }, { key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: }, { key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: }, ] 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([]) const [settings, setSettings] = useState(null) const [loadingChannels, setLoadingChannels] = useState(false) const [loadingSettings, setLoadingSettings] = useState(false) const [saving, setSaving] = useState(false) const [togglingId, setTogglingId] = useState(null) const [staffList, setStaffList] = useState([]) const [seatLimit, setSeatLimit] = useState(0) const [seatUsed, setSeatUsed] = useState(0) const [loadingStaff, setLoadingStaff] = useState(false) const [staffModalOpen, setStaffModalOpen] = useState(false) const [editingStaff, setEditingStaff] = useState(null) const [savingStaff, setSavingStaff] = useState(false) const [welcomeSegments, setWelcomeSegments] = useState([ { type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }, ]) const [uploadingWelcomeIdx, setUploadingWelcomeIdx] = useState(null) const [customerTags, setCustomerTags] = useState([]) const [loadingTags, setLoadingTags] = useState(false) const [tagModalOpen, setTagModalOpen] = useState(false) const [editingTag, setEditingTag] = useState(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 = { 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[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') ? ( ) : activeTab === 'channels' && missingTypes.length > 0 ? ( ) : null return (
{/* 左侧设置导航 — 顶栏 56px */} {/* 右侧内容 */}

{currentTab.label}

{currentTab.desc}

{panelHeaderAction} {activeTab === 'staff' && isAdmin && ( )}

{currentTab.desc}

{activeTab === 'basic' && (
{loadingSettings && !settings ? (
) : (
savePartial({ display_name: values.display_name, agent_nickname: values.agent_nickname, timezone: values.timezone, })} >
)}
)} {activeTab === 'channels' && (
{loadingChannels ? (
) : channels.length === 0 ? (
) : (
{channels.map(ch => { const meta = typeMeta[ch.type] || { icon: , label: ch.name || ch.type, desc: '' } const enabled = ch.status === 'enabled' return (
{meta.icon}

{ch.name || meta.label}

{enabled ? '已启用' : '未启用'}
toggleChannel(ch, v)} />
{meta.desc && (

{meta.desc}

)} {ch.channel_key && (
渠道 ID {ch.channel_key}
)} {ch.type === 'web' && ch.script_code && (
接入代码
{ch.script_code.replace( 'src="/widget.js"', `src="${window.location.origin}/widget.js"`, )}
)} {ch.type !== 'web' && (
本期仅网页渠道可完整接入,其他类型预留开关。
)}
) })}
)} {missingTypes.length > 0 && channels.length > 0 && (
可添加渠道类型
{missingTypes.map(t => ( ))}
)}
)} {activeTab === 'assignment' && (
{loadingSettings ? (
) : ( <>
自动分配规则
工作时间内,访客进线会按下列策略分配给在线一线客服; 非工作时间或无可接坐席时进入排队/留言。人工领取与转接不受此策略限制。
{ await savePartial({ assign_strategy: values.assign_strategy, max_active_per_agent: Number(values.max_active_per_agent) || 0, }, '分配规则已保存') }} > {isAdmin ? ( ) : (

仅租户管理员可修改分配规则

)}
说明

· 候选范围:角色为客服、状态为「在线」的账号(忙碌/离线不参与自动分配)。

· 技能组 / 渠道专属坐席将在后续版本支持。

)}
)} {activeTab === 'staff' && (
{!canViewStaff ? (
仅管理员或主管可查看坐席列表
) : loadingStaff ? (
) : ( <>
坐席占用
{seatUsed} / {seatLimit}
启用中的管理员、主管、客服各占 1 个坐席。禁用账号释放配额。角色:客服接待会话;主管可看统计与客户;管理员可改系统设置。
{staffList.length === 0 ? ( ) : (
{isAdmin && ( )} {staffList.map(s => { const st = statusLabel[s.status] || statusLabel.offline return ( {isAdmin && ( )} ) })}
账号 昵称 角色 状态 创建时间操作
{s.username} {s.nickname || '—'} {roleLabel[s.role] || s.role} {st.text} {s.created_at ? s.created_at.slice(0, 16) : '—'} e.stopPropagation()}>
{s.status === 'disabled' ? ( ) : ( handleDisableStaff(s)} disabled={s.id === user?.user_id} > )}
)} )}
)} {activeTab === 'autoreply' && (
{loadingSettings && !settings ? (
) : (
{ 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, }, '自动回复已保存') }} >
欢迎语(多段)

访客打开窗口且尚无消息时按顺序展示;可混排富文本与图片,最多 {MAX_WELCOME_SEGMENTS} 段。 选中文字可设置颜色、字号、加粗等(仅欢迎语;正常聊天仍为 Markdown)。

{welcomeSegments.map((seg, idx) => (
第 {idx + 1} 段 · {seg.type === 'image' ? '图片' : '文本'}
{seg.type === 'text' ? ( { setWelcomeSegments(prev => prev.map((s, i) => (i === idx ? { ...s, content: v } : s)), ) }} /> ) : (
{seg.content ? ( {`欢迎图 ) : (
未上传
)} { 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) } }} >
)}
))}
)}
)} {activeTab === 'worktime' && (
{loadingSettings && !settings ? (
) : (
{ 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, }, '工作时间已保存') }} >

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

{weekDays.map((day, i) => (
{day.label} )}
)} {activeTab === 'notify' && (
{loadingSettings && !settings ? (
) : (
savePartial({ notify_new_session: values.notify_new_session, notify_offline_leave: values.notify_offline_leave, notify_daily_report: values.notify_daily_report, }, '通知设置已保存')} >
{[ { name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒;浏览器通知与声音可在工作台铃铛中配置' }, { name: 'notify_offline_leave', title: '离线留言通知', desc: '访客提交离线留言时提醒' }, { name: 'notify_daily_report', title: '日报推送', desc: '每日服务数据摘要' }, ].map(item => (
{item.title}
{item.desc}
))}
)}
)} {activeTab === 'tags' && (

在此创建标签后,客户管理与工作台中员工只能从列表选择,不能随意输入新标签。

{loadingTags ? (
) : customerTags.length === 0 ? ( ) : (
{customerTags.map(tag => { const style = tagColorStyle(tag.color) return (
{tag.name}
) })}
)}
)}
setTagModalOpen(false)} onOk={() => tagForm.submit()} confirmLoading={savingTag} destroyOnHidden okText="保存" width={400} >
{ 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) } }} > )} )}
) } export default Settings