feat(history): establish Telegram SaaS platform foundation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Settings, ShieldCheck, KeyRound, Bell, Globe, Database, ServerCog, Languages, UserCog, Palette, FileUp, FileDown, Clock } from 'lucide-react'
|
||||
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'
|
||||
@@ -8,22 +8,18 @@ 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 { Tabs } from '@/components/ui/tabs'
|
||||
import { PermissionGate } from '@/components/common/PermissionGate'
|
||||
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: 'brand', label: '品牌设置', icon: Palette },
|
||||
{ key: 'lang', label: '语言与时区', icon: Globe },
|
||||
{ key: 'notifications', label: '消息通知', icon: Bell },
|
||||
{ key: 'ai', label: 'AI回复设置', icon: Languages },
|
||||
{ key: 'translate', label: '翻译设置', icon: Languages },
|
||||
{ key: 'safety', label: '安全设置', icon: ShieldCheck },
|
||||
{ key: 'api', label: 'API与Webhook', icon: KeyRound },
|
||||
{ key: 'import-export', label: '数据导入导出', icon: Database },
|
||||
{ key: 'deploy', label: '私有化部署', icon: ServerCog },
|
||||
{ key: 'api', label: 'API Key', icon: KeyRound },
|
||||
]
|
||||
|
||||
export function SettingsPage() {
|
||||
@@ -31,6 +27,26 @@ export function SettingsPage() {
|
||||
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),
|
||||
@@ -52,12 +68,12 @@ export function SettingsPage() {
|
||||
<Card>
|
||||
<CardHeader title="企业资料" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4">
|
||||
<Field label="企业名称"><Input defaultValue={settings.company.name} onChange={(e) => updateMut.mutate({ company: { ...settings.company, name: e.target.value } })} /></Field>
|
||||
<Field label="所属行业"><Input defaultValue={settings.company.industry} /></Field>
|
||||
<Field label="联系方式"><Input defaultValue={settings.company.contact} /></Field>
|
||||
<Field label="邮箱"><Input defaultValue={settings.company.email} /></Field>
|
||||
<Field label="企业简介" className="md:col-span-2"><Input defaultValue={settings.company.intro} /></Field>
|
||||
<div className="md:col-span-2"><Button onClick={() => toast.success('企业资料已保存')}>保存修改</Button></div>
|
||||
<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>
|
||||
)}
|
||||
@@ -112,12 +128,19 @@ export function SettingsPage() {
|
||||
<Card>
|
||||
<CardHeader title="安全设置" />
|
||||
<div className="space-y-4 p-4">
|
||||
<Field label="修改密码"><Input type="password" placeholder="输入新密码…" /></Field>
|
||||
<div className="flex items-center justify-between"><span className="text-sm">两步验证</span><Switch checked={false} onCheckedChange={() => toast.info('两步验证设置(演示)')} /></div>
|
||||
<div className="flex items-center justify-between"><span className="text-sm">数据导出权限</span><Switch checked={true} /></div>
|
||||
<Field label="会话保存周期"><Select options={[{value:'6',label:'6个月'},{value:'12',label:'12个月'},{value:'24',label:'24个月'},{value:'forever',label:'永久'}]} /></Field>
|
||||
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />全链路加密已启用(TLS 1.3 + AES-256)</div>
|
||||
<Button onClick={() => toast.success('安全设置已保存')}>保存</Button>
|
||||
<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 bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />密码使用 bcrypt 加密;修改后所有旧登录 Token 会立即失效。</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>
|
||||
)}
|
||||
@@ -125,9 +148,34 @@ export function SettingsPage() {
|
||||
<Card>
|
||||
<CardHeader title="API 与 Webhook" />
|
||||
<div className="space-y-3 p-4">
|
||||
<p className="text-sm text-muted-foreground">通过 API 或 Webhook 将灵犀跨境的客户数据、消息与风险预警接入您的自有系统。</p>
|
||||
<div className="rounded bg-muted p-3 font-mono text-sm break-all">lx_live_8f2k••••••••9d21</div>
|
||||
<Button variant="outline" onClick={() => toast.success('新 API Key 已生成')}>生成新 Key</Button>
|
||||
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,数据库仅保存 SHA-256 摘要。</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-border rounded-lg border border-border">
|
||||
{(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>
|
||||
)}
|
||||
@@ -136,28 +184,16 @@ export function SettingsPage() {
|
||||
<CardHeader title="语言与时区" />
|
||||
<div className="space-y-4 p-4">
|
||||
<Field label="界面语言">
|
||||
<Select value={locale} options={[{value:'zh',label:'简体中文'},{value:'en',label:'English'},{value:'ar',label:'العربية (RTL)'}]} onChange={(e) => setLocale(e.target.value as any)} />
|
||||
<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 options={[{value:'Asia/Shanghai',label:'东八区 (UTC+8)'},{value:'Asia/Dubai',label:'迪拜 (UTC+4)'},{value:'America/New_York',label:'纽约 (UTC-5)'}]} /></Field>
|
||||
<Button onClick={() => toast.success('语言与时区已保存')}>保存</Button>
|
||||
<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>
|
||||
)}
|
||||
{section === 'deploy' && (
|
||||
<Card>
|
||||
<CardHeader title="私有化部署咨询" />
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">增长版/定制版支持本地或私有云部署,数据不出企业、全链路审计。</p>
|
||||
<p className="text-sm">📞 咨询热线:0516-8888-6621(演示)</p>
|
||||
<p className="text-sm">📧 邮箱:sales@ouhui.tech</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{![ 'company', 'ai', 'notifications', 'safety', 'api', 'lang', 'deploy'].includes(section) && (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{section === 'brand' ? '品牌设置' : section === 'translate' ? '翻译设置' : section === 'import-export' ? '数据导入导出' : '此设置模块'}在演示环境中为简化版,接入后端后可完整配置。</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user