af49d22ca7
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
221 lines
13 KiB
TypeScript
221 lines
13 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { ShieldCheck, KeyRound, Bell, Globe, Languages, UserCog, Copy, Trash2 } from 'lucide-react'
|
||
import { toast, errorMessage } from '@/app/store/toast'
|
||
import { PageHeader } from '@/components/common/PageHeader'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardHeader } from '@/components/ui/card'
|
||
import { Input, Field } from '@/components/ui/input'
|
||
import { Select } from '@/components/ui/select'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { getSettings, updateSettings } from '@/services/api/org'
|
||
import { useI18n } from '@/i18n'
|
||
import { changePassword, createApiKey, getApiKeys, revokeApiKey } from '@/services/api/auth'
|
||
import { useAuthStore } from '@/app/store/auth'
|
||
|
||
const SECTIONS = [
|
||
{ key: 'company', label: '企业资料', icon: UserCog },
|
||
{ key: 'lang', label: '语言与时区', icon: Globe },
|
||
{ key: 'notifications', label: '消息通知', icon: Bell },
|
||
{ key: 'ai', label: '智能回复', icon: Languages },
|
||
{ key: 'safety', label: '安全设置', icon: ShieldCheck },
|
||
{ key: 'api', label: 'API Key', icon: KeyRound },
|
||
]
|
||
|
||
export function SettingsPage() {
|
||
const queryClient = useQueryClient()
|
||
const [section, setSection] = useState('company')
|
||
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
|
||
const { locale, setLocale, t } = useI18n()
|
||
const logout = useAuthStore((state) => state.logout)
|
||
const [companyDraft, setCompanyDraft] = useState({ name: '', industry: '', contact: '', email: '', intro: '' })
|
||
const [passwords, setPasswords] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||
const [localeDraft, setLocaleDraft] = useState(locale)
|
||
const [timezoneDraft, setTimezoneDraft] = useState('Asia/Hong_Kong')
|
||
const [apiName, setApiName] = useState('业务集成')
|
||
const [newApiKey, setNewApiKey] = useState('')
|
||
const { data: apiKeys } = useQuery({ queryKey: ['api-keys'], queryFn: getApiKeys, enabled: section === 'api' })
|
||
|
||
useEffect(() => {
|
||
if (settings?.company) setCompanyDraft({
|
||
name: settings.company.name || '',
|
||
industry: settings.company.industry || '',
|
||
contact: settings.company.contact || '',
|
||
email: settings.company.email || '',
|
||
intro: settings.company.intro || '',
|
||
})
|
||
if (settings?.language) setLocaleDraft(settings.language.startsWith('zh') ? 'zh' : settings.language as any)
|
||
if (settings?.timezone) setTimezoneDraft(settings.timezone)
|
||
}, [settings])
|
||
|
||
const updateMut = useMutation({
|
||
mutationFn: (payload: any) => updateSettings(payload),
|
||
onSuccess: () => { toast.success('设置已保存'); queryClient.invalidateQueries({ queryKey: ['settings'] }) },
|
||
onError: (e) => toast.error(errorMessage(e)),
|
||
})
|
||
|
||
return (
|
||
<div className="page-shell">
|
||
<PageHeader title="设置" description="管理企业资料、通知、智能能力与账号安全" />
|
||
|
||
<div className="mb-4 rounded-[12px] border border-slate-200 bg-white p-2 shadow-sm md:hidden">
|
||
<Select
|
||
aria-label="设置分类"
|
||
value={section}
|
||
onChange={(event) => setSection(event.target.value)}
|
||
className="w-full"
|
||
options={SECTIONS.map((item) => ({ value: item.key, label: item.label }))}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid items-start gap-5 md:grid-cols-[220px_minmax(0,1fr)]">
|
||
<div className="sticky top-4 hidden space-y-1 rounded-[14px] border border-slate-200 bg-white p-2 shadow-sm md:block">
|
||
<p className="px-3 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-400">设置分类</p>
|
||
{SECTIONS.map((s) => (
|
||
<button key={s.key} onClick={() => setSection(s.key)} className={`flex w-full items-center gap-2 rounded-[9px] px-3 py-2.5 text-sm transition ${section === s.key ? 'bg-primary-50 font-semibold text-primary-700 shadow-[inset_0_0_0_1px_rgba(37,99,235,.08)]' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'}`}>
|
||
<s.icon className="h-4 w-4" />{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="min-w-0">
|
||
{section === 'company' && settings && (
|
||
<Card>
|
||
<CardHeader title="企业资料" description="用于团队空间、通知邮件和对外业务资料" />
|
||
<div className="grid grid-cols-1 gap-5 p-5 md:grid-cols-2">
|
||
<Field label="企业名称"><Input value={companyDraft.name} onChange={(e) => setCompanyDraft((v) => ({ ...v, name: e.target.value }))} /></Field>
|
||
<Field label="所属行业"><Input value={companyDraft.industry} onChange={(e) => setCompanyDraft((v) => ({ ...v, industry: e.target.value }))} /></Field>
|
||
<Field label="联系方式"><Input value={companyDraft.contact} onChange={(e) => setCompanyDraft((v) => ({ ...v, contact: e.target.value }))} /></Field>
|
||
<Field label="邮箱"><Input value={companyDraft.email} onChange={(e) => setCompanyDraft((v) => ({ ...v, email: e.target.value }))} /></Field>
|
||
<Field label="企业简介" className="md:col-span-2"><Input value={companyDraft.intro} onChange={(e) => setCompanyDraft((v) => ({ ...v, intro: e.target.value }))} /></Field>
|
||
<div className="md:col-span-2"><Button onClick={() => updateMut.mutate({ company: companyDraft })} loading={updateMut.isPending}>保存修改</Button></div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
{section === 'ai' && settings && (
|
||
<Card>
|
||
<CardHeader title="智能回复" description="控制回复建议、分析和发送前确认规则" />
|
||
<div className="divide-y divide-slate-100 px-5">
|
||
{[
|
||
{ key: 'autoGenerate', label: '自动生成回复建议' },
|
||
{ key: 'requireConfirm', label: '发送前必须人工确认' },
|
||
{ key: 'sentimentAnalysis', label: '启用情感分析' },
|
||
{ key: 'messageIntentAnalysis', label: '逐条客户消息意图识别' },
|
||
{ key: 'contextualTranslation', label: '翻译时参考会话上下文' },
|
||
{ key: 'riskAlert', label: '启用风险预警' },
|
||
{ key: 'cultureCheck', label: '启用文化敏感检测' },
|
||
{ key: 'allowHistoryRef', label: '允许AI引用历史成功回复' },
|
||
].map((item) => (
|
||
<div key={item.key} className="flex items-center justify-between py-4">
|
||
<span className="text-sm">{item.label}</span>
|
||
<Switch
|
||
ariaLabel={item.label}
|
||
checked={['messageIntentAnalysis', 'contextualTranslation'].includes(item.key)
|
||
? (settings.ai as any)[item.key] !== false
|
||
: Boolean((settings.ai as any)[item.key])}
|
||
onCheckedChange={(v) => updateMut.mutate({ ai: { [item.key]: v } })}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
{section === 'notifications' && settings && (
|
||
<Card>
|
||
<CardHeader title="消息通知" description="选择需要发送到通知中心的业务事件" />
|
||
<div className="divide-y divide-slate-100 px-5">
|
||
{[
|
||
{ key: 'newInquiry', label: '新询盘提醒' },
|
||
{ key: 'highIntent', label: '高意向客户提醒' },
|
||
{ key: 'riskAlert', label: '风险预警提醒' },
|
||
{ key: 'taskDue', label: '跟进到期提醒' },
|
||
{ key: 'taskOverdue', label: '跟进逾期提醒' },
|
||
{ key: 'channelOffline', label: '渠道断开提醒' },
|
||
{ key: 'planExpire', label: '套餐到期提醒' },
|
||
].map((item) => (
|
||
<div key={item.key} className="flex items-center justify-between py-4">
|
||
<span className="text-sm">{item.label}</span>
|
||
<Switch
|
||
checked={(settings.notification as any)[item.key]}
|
||
onCheckedChange={(v) => updateMut.mutate({ notification: { [item.key]: v } })}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
)}
|
||
{section === 'safety' && (
|
||
<Card>
|
||
<CardHeader title="安全设置" description="修改密码后会撤销其他设备上的登录会话" />
|
||
<div className="space-y-5 p-5">
|
||
<Field label="当前密码"><Input type="password" value={passwords.currentPassword} onChange={(e) => setPasswords((v) => ({ ...v, currentPassword: e.target.value }))} /></Field>
|
||
<Field label="新密码"><Input type="password" placeholder="至少10位,包含字母和数字" value={passwords.newPassword} onChange={(e) => setPasswords((v) => ({ ...v, newPassword: e.target.value }))} /></Field>
|
||
<Field label="确认新密码"><Input type="password" value={passwords.confirmPassword} onChange={(e) => setPasswords((v) => ({ ...v, confirmPassword: e.target.value }))} /></Field>
|
||
<div className="rounded-[10px] border border-primary-100 bg-primary-50 p-3 text-sm text-primary-700"><ShieldCheck className="mr-1 inline h-4 w-4" />修改密码后,其他已登录设备需要重新登录。</div>
|
||
<Button onClick={async () => {
|
||
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
|
||
try {
|
||
await changePassword({ currentPassword: passwords.currentPassword, newPassword: passwords.newPassword })
|
||
toast.success('密码已修改,请重新登录')
|
||
logout()
|
||
window.location.assign('/login')
|
||
} catch (error) { toast.error(errorMessage(error)) }
|
||
}}>修改密码并撤销旧会话</Button>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
{section === 'api' && (
|
||
<Card>
|
||
<CardHeader title="API Key" description="为自有系统或服务端集成创建访问凭据" />
|
||
<div className="space-y-4 p-5">
|
||
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,请立即复制并妥善保存。</p>
|
||
<div className="flex gap-2">
|
||
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
|
||
<Button variant="outline" onClick={async () => {
|
||
try {
|
||
const result = await createApiKey(apiName)
|
||
setNewApiKey(result.key || '')
|
||
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||
} catch (error) { toast.error(errorMessage(error)) }
|
||
}}>生成新 Key</Button>
|
||
</div>
|
||
{newApiKey && (
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
|
||
<p className="text-xs font-medium text-amber-800">请立即复制,关闭后无法再次查看</p>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<code className="min-w-0 flex-1 break-all text-xs">{newApiKey}</code>
|
||
<Button size="icon-sm" variant="ghost" onClick={() => navigator.clipboard.writeText(newApiKey).then(() => toast.success('已复制'))}><Copy className="h-4 w-4" /></Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="divide-y divide-slate-100 overflow-hidden rounded-[10px] border border-slate-200">
|
||
{(apiKeys || []).map((key) => (
|
||
<div key={key.id} className="flex items-center gap-3 px-3 py-2">
|
||
<div className="min-w-0 flex-1"><p className="text-sm font-medium">{key.name}</p><p className="text-xs text-muted-foreground">{key.prefix}•••••• · {key.revoked ? '已撤销' : '有效'}</p></div>
|
||
{!key.revoked && <Button size="icon-sm" variant="ghost" onClick={() => revokeApiKey(key.id).then(() => queryClient.invalidateQueries({ queryKey: ['api-keys'] }))}><Trash2 className="h-4 w-4 text-red-500" /></Button>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
{section === 'lang' && (
|
||
<Card>
|
||
<CardHeader title="语言与时区" description="调整界面显示语言与业务时间基准" />
|
||
<div className="space-y-5 p-5">
|
||
<Field label="界面语言">
|
||
<Select value={localeDraft} options={[{value:'zh',label:'简体中文'},{value:'en',label:'English'},{value:'ar',label:'العربية (RTL)'}]} onChange={(e) => setLocaleDraft(e.target.value as any)} />
|
||
</Field>
|
||
<Field label="时区"><Select value={timezoneDraft} onChange={(e) => setTimezoneDraft(e.target.value)} options={[{value:'Asia/Hong_Kong',label:'香港 (UTC+8)'},{value:'Asia/Shanghai',label:'上海 (UTC+8)'},{value:'Asia/Dubai',label:'迪拜 (UTC+4)'},{value:'America/New_York',label:'纽约 (UTC-5)'}]} /></Field>
|
||
<Button loading={updateMut.isPending} onClick={() => updateMut.mutate(
|
||
{ language: localeDraft === 'zh' ? 'zh-CN' : localeDraft, timezone: timezoneDraft },
|
||
{ onSuccess: () => setLocale(localeDraft as any) },
|
||
)}>保存</Button>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|