493 lines
18 KiB
TypeScript
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
|