Files
lingxi-web/src/pages/settings/SettingsPage.tsx
T
wmq af49d22ca7 feat: release contextual omnichannel customer platform
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.
2026-07-30 02:29:53 +08:00

221 lines
13 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 } 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>
)
}