227 lines
7.7 KiB
TypeScript
227 lines
7.7 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { KeyRound, Shield, Terminal } from 'lucide-react'
|
||
|
||
import { Button } from '@/components/ui/button'
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { AdminUnauthorizedError, API_BASE, adminFetchJson, clearAdminToken } from '@/lib/admin-api'
|
||
|
||
export default function SettingsPage() {
|
||
const router = useRouter()
|
||
const [username, setUsername] = useState<string>('')
|
||
const [tokenExp, setTokenExp] = useState<number | null>(null)
|
||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||
|
||
const [oldPassword, setOldPassword] = useState('')
|
||
const [newPassword, setNewPassword] = useState('')
|
||
const [confirmPassword, setConfirmPassword] = useState('')
|
||
|
||
const [isSaving, setIsSaving] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [success, setSuccess] = useState('')
|
||
|
||
const fetchMe = async () => {
|
||
setError('')
|
||
try {
|
||
const data = await adminFetchJson<{ username?: string; exp?: number }>('/api/v1/admin/me')
|
||
setUsername(data.username || '')
|
||
setTokenExp(typeof data.exp === 'number' ? data.exp : null)
|
||
} catch (err: any) {
|
||
if (err instanceof AdminUnauthorizedError) {
|
||
router.replace('/login')
|
||
return
|
||
}
|
||
setError(err?.message || '加载失败')
|
||
}
|
||
}
|
||
|
||
const fetchHealth = async () => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/health`)
|
||
setHealthOk(res.ok)
|
||
} catch {
|
||
setHealthOk(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
fetchMe()
|
||
fetchHealth()
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [])
|
||
|
||
const handleChangePassword = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setError('')
|
||
setSuccess('')
|
||
|
||
if (!oldPassword.trim() || !newPassword.trim()) {
|
||
setError('请输入原密码和新密码')
|
||
return
|
||
}
|
||
if (newPassword.trim().length < 8) {
|
||
setError('新密码至少 8 位')
|
||
return
|
||
}
|
||
if (newPassword !== confirmPassword) {
|
||
setError('两次输入的新密码不一致')
|
||
return
|
||
}
|
||
|
||
setIsSaving(true)
|
||
try {
|
||
await adminFetchJson('/api/v1/admin/change-password', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
old_password: oldPassword,
|
||
new_password: newPassword,
|
||
}),
|
||
})
|
||
setSuccess('密码已更新,请重新登录')
|
||
clearAdminToken()
|
||
router.replace('/login')
|
||
} catch (err: any) {
|
||
if (err instanceof AdminUnauthorizedError) {
|
||
router.replace('/login')
|
||
return
|
||
}
|
||
setError(err?.message || '更新失败')
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const expText = tokenExp ? new Date(tokenExp * 1000).toLocaleString() : '未知'
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold tracking-tight">设置</h1>
|
||
<p className="text-sm text-muted-foreground">账号安全与系统信息</p>
|
||
</div>
|
||
|
||
{(error || success) && (
|
||
<div
|
||
className={
|
||
success
|
||
? 'rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300'
|
||
: 'rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive'
|
||
}
|
||
>
|
||
{success || error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<Card className="border-muted bg-background/50 backdrop-blur-sm">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Shield className="h-5 w-5" />
|
||
账号安全
|
||
</CardTitle>
|
||
<CardDescription>查看登录信息并更新管理员密码</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="rounded-md border bg-background p-4 space-y-2">
|
||
<div className="text-sm text-muted-foreground">当前用户</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="text-lg font-semibold">{username || '—'}</div>
|
||
{username && <Badge variant="secondary">管理员</Badge>}
|
||
</div>
|
||
<div className="text-xs text-muted-foreground">Token 过期时间:{expText}</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleChangePassword} className="space-y-4">
|
||
<div className="space-y-1.5">
|
||
<Label>原密码</Label>
|
||
<Input
|
||
type="password"
|
||
value={oldPassword}
|
||
onChange={(e) => setOldPassword(e.target.value)}
|
||
autoComplete="current-password"
|
||
/>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>新密码</Label>
|
||
<Input
|
||
type="password"
|
||
value={newPassword}
|
||
onChange={(e) => setNewPassword(e.target.value)}
|
||
autoComplete="new-password"
|
||
/>
|
||
<div className="text-xs text-muted-foreground">至少 8 位</div>
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>确认新密码</Label>
|
||
<Input
|
||
type="password"
|
||
value={confirmPassword}
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
autoComplete="new-password"
|
||
/>
|
||
</div>
|
||
|
||
<Button type="submit" disabled={isSaving} className="w-full">
|
||
<KeyRound className="mr-2 h-4 w-4" />
|
||
{isSaving ? '更新中...' : '更新密码'}
|
||
</Button>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="border-muted bg-background/50 backdrop-blur-sm">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Terminal className="h-5 w-5" />
|
||
系统信息
|
||
</CardTitle>
|
||
<CardDescription>当前 API Base 与健康检查</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="rounded-md border bg-background p-4 space-y-2">
|
||
<div className="text-sm text-muted-foreground">API Base URL</div>
|
||
<div className="font-mono text-xs break-all">{API_BASE}</div>
|
||
</div>
|
||
|
||
<div className="rounded-md border bg-background p-4 space-y-2">
|
||
<div className="text-sm text-muted-foreground">API 健康</div>
|
||
<div className="flex items-center gap-2">
|
||
{healthOk === null ? (
|
||
<Badge variant="outline">未知</Badge>
|
||
) : healthOk ? (
|
||
<Badge variant="success">OK</Badge>
|
||
) : (
|
||
<Badge variant="destructive">异常</Badge>
|
||
)}
|
||
<Button variant="outline" size="sm" onClick={fetchHealth}>
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="text-xs text-muted-foreground">
|
||
若健康检查异常,请确认后端服务可访问,并检查数据库/Redis 配置。
|
||
</div>
|
||
|
||
<Button
|
||
variant="destructive"
|
||
onClick={() => {
|
||
clearAdminToken()
|
||
router.replace('/login')
|
||
}}
|
||
>
|
||
退出登录
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|