Files
kefu_cloud/web/src/pages/agent/Settings.tsx
T
yml2213 24fac1bcf1
CI / Backend (go vet + test) (push) Failing after 40m21s
CI / Frontend (typecheck + lint + build) (push) Successful in 1h21m3s
fix(widget): 完善自动回复与新会话进线提醒
2026-11-03 19:29:32 +08:00

493 lines
18 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { Button, Form, message } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import {
createChannel, createStaff, deleteStaff,
getChannels, getCustomerTags, getStaff, getTenantSettings,
updateChannel, updateStaff, updateTenantSettings,
type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WelcomeSegment,
} from '@/services/api'
import { useAuth, usePermission } from '@/stores/auth'
import { tabGroups, tabItems } from './settings/tabs'
import { typeMeta } from './settings/channelMeta'
import { normalizeWelcomeFromSettings } from './settings/welcome'
import { parseWorkSlot, weekDays } from './settings/worktime'
import BasicSection from './settings/BasicSection'
import ChannelsSection from './settings/ChannelsSection'
import AssignmentSection from './settings/AssignmentSection'
import StaffSection from './settings/StaffSection'
import StaffModal from './settings/StaffModal'
import AutoReplySection from './settings/AutoReplySection'
import WorktimeSection from './settings/WorktimeSection'
import NotifySection from './settings/NotifySection'
import TagsSection from './settings/TagsSection'
import TagModal from './settings/TagModal'
const Settings = () => {
const { user, permissions } = useAuth()
const canViewStaff = usePermission('settings.staff')
const canEditAssignment = usePermission('settings.assign_rule')
const visibleTabGroups = useMemo(
() => tabGroups
.map(group => ({ ...group, items: group.items.filter(item => permissions.has(item.permission)) }))
.filter(group => group.items.length > 0),
[permissions],
)
const visibleTabItems = useMemo(() => visibleTabGroups.flatMap(group => group.items), [visibleTabGroups])
const [activeTab, setActiveTab] = useState(() => visibleTabItems[0]?.key || '')
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 = visibleTabItems.find(t => t.key === activeTab) || visibleTabItems[0] || 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 (visibleTabItems.length > 0 && !visibleTabItems.some(item => item.key === activeTab)) {
setActiveTab(visibleTabItems[0].key)
}
}, [activeTab, visibleTabItems])
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 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
const currentGroup = visibleTabGroups.find(g => g.items.some(i => i.key === activeTab))
return (
<div className="h-full flex min-h-0 overflow-hidden bg-slate-50/60">
{/* 左侧设置导航 — 顶栏 56px */}
<aside className="w-[230px] 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-3 space-y-4">
{visibleTabGroups.map(group => (
<div key={group.groupName} className="space-y-1">
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-wider px-2.5 mb-1 select-none">
{group.groupName}
</div>
{group.items.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/90 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 shrink-0 ${active ? 'text-blue-600' : 'text-slate-400'}`}>
{item.icon}
</span>
<span className="truncate flex-1">{item.label}</span>
</button>
)
})}
</div>
))}
</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">
<div className="flex items-center gap-1.5 text-[11px] text-slate-400 font-medium leading-none mb-1">
<span>系统设置</span>
<span>/</span>
<span className="text-slate-600">{currentGroup?.groupName || '配置'}</span>
<span>/</span>
<span className="text-blue-600 font-semibold">{currentTab.label}</span>
</div>
<p className="text-xs text-slate-500 m-0 leading-tight truncate">{currentTab.desc}</p>
</div>
{panelHeaderAction}
{activeTab === 'staff' && canViewStaff && (
<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 p-6 md:p-8">
<div className="max-w-5xl mx-auto w-full">
{activeTab === 'basic' && (
<BasicSection
basicForm={basicForm}
loading={loadingSettings && !settings}
saving={saving}
onSave={savePartial}
/>
)}
{activeTab === 'channels' && (
<ChannelsSection
loadingChannels={loadingChannels}
channels={channels}
missingTypes={missingTypes}
togglingId={togglingId}
toggleChannel={toggleChannel}
ensureWebChannel={ensureWebChannel}
addChannelType={addChannelType}
/>
)}
{activeTab === 'assignment' && (
<AssignmentSection
assignForm={assignForm}
loading={loadingSettings && !settings}
isAdmin={canEditAssignment}
saving={saving}
onSave={savePartial}
/>
)}
{activeTab === 'staff' && (
<StaffSection
canViewStaff={canViewStaff}
loadingStaff={loadingStaff}
staffList={staffList}
isAdmin={canViewStaff}
seatUsed={seatUsed}
seatLimit={seatLimit}
currentUserId={user?.user_id}
openEditStaff={openEditStaff}
handleEnableStaff={handleEnableStaff}
handleDisableStaff={handleDisableStaff}
/>
)}
{activeTab === 'autoreply' && (
<AutoReplySection
autoReplyForm={autoReplyForm}
loading={loadingSettings && !settings}
saving={saving}
welcomeSegments={welcomeSegments}
setWelcomeSegments={setWelcomeSegments}
uploadingWelcomeIdx={uploadingWelcomeIdx}
setUploadingWelcomeIdx={setUploadingWelcomeIdx}
onSave={savePartial}
/>
)}
{activeTab === 'worktime' && (
<WorktimeSection
workForm={workForm}
loading={loadingSettings && !settings}
saving={saving}
onSave={savePartial}
/>
)}
{activeTab === 'notify' && (
<NotifySection
notifyForm={notifyForm}
loading={loadingSettings && !settings}
saving={saving}
onSave={savePartial}
/>
)}
{activeTab === 'tags' && (
<TagsSection
customerTags={customerTags}
loadingTags={loadingTags}
tagForm={tagForm}
setEditingTag={setEditingTag}
setTagModalOpen={setTagModalOpen}
loadCustomerTags={loadCustomerTags}
/>
)}
</div>
</div>
</section>
<TagModal
tagModalOpen={tagModalOpen}
setTagModalOpen={setTagModalOpen}
editingTag={editingTag}
savingTag={savingTag}
setSavingTag={setSavingTag}
tagForm={tagForm}
loadCustomerTags={loadCustomerTags}
/>
<StaffModal
staffModalOpen={staffModalOpen}
setStaffModalOpen={setStaffModalOpen}
editingStaff={editingStaff}
savingStaff={savingStaff}
isAdmin={canViewStaff}
staffForm={staffForm}
onFinish={handleSaveStaff}
/>
</div>
)
}
export default Settings