mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +08:00
wavelet init
This commit is contained in:
@@ -0,0 +1,426 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {motion} from "motion/react"
|
||||
import {AlertTriangle, Check, Copy, Info, Key, Loader2, Plus, RefreshCw, Shield, Trash2} 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 {Switch} from "@/components/ui/switch"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import type {CreateTokenResponse} from "@/lib/services/user"
|
||||
import {UserService} from "@/lib/services/user"
|
||||
import {useAuth} from "@/components/providers/auth-provider"
|
||||
import {toast} from "sonner"
|
||||
|
||||
export function AccessTokenMain() {
|
||||
const { user } = useAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
|
||||
const [viewDialogOpen, setViewDialogOpen] = React.useState(false)
|
||||
const [tokenName, setTokenName] = React.useState("")
|
||||
const [tokenIsAdmin, setTokenIsAdmin] = React.useState(false)
|
||||
const [copiedId, setCopiedId] = React.useState<number | null>(null)
|
||||
const [newCreatedToken, setNewCreatedToken] = React.useState<CreateTokenResponse | null>(null)
|
||||
|
||||
// 获取 Token 列表
|
||||
const accessTokensQuery = useQuery({
|
||||
queryKey: ["user", "access-tokens"],
|
||||
queryFn: () => UserService.getAccessTokens(),
|
||||
})
|
||||
|
||||
// 创建 Token
|
||||
const createTokenMutation = useMutation({
|
||||
mutationFn: ({ name, isAdmin }: { name: string; isAdmin: boolean }) => UserService.createAccessToken(name, isAdmin),
|
||||
onSuccess: (data) => {
|
||||
setNewCreatedToken(data)
|
||||
setTokenName("")
|
||||
setTokenIsAdmin(false)
|
||||
setCreateDialogOpen(false)
|
||||
setViewDialogOpen(true)
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌创建成功")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "创建访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
// 删除 Token
|
||||
const deleteTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.deleteAccessToken(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌已撤销")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
// 轮换 Token
|
||||
const rotateTokenMutation = useMutation({
|
||||
mutationFn: (id: number) => UserService.rotateAccessToken(id),
|
||||
onSuccess: (data) => {
|
||||
setNewCreatedToken(data)
|
||||
setViewDialogOpen(true)
|
||||
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
|
||||
toast.success("访问令牌轮换成功,旧密钥已失效")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "轮换访问令牌失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreateToken = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!tokenName.trim()) {
|
||||
toast.error("请输入令牌名称")
|
||||
return
|
||||
}
|
||||
createTokenMutation.mutate({ name: tokenName.trim(), isAdmin: tokenIsAdmin })
|
||||
}
|
||||
|
||||
const handleDeleteToken = (id: number, name: string) => {
|
||||
if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) {
|
||||
deleteTokenMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRotateToken = (id: number, name: string) => {
|
||||
if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) {
|
||||
rotateTokenMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyText = async (text: string, id: number) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedId(id)
|
||||
toast.success("复制成功")
|
||||
setTimeout(() => setCopiedId(null), 2000)
|
||||
} catch {
|
||||
toast.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateStr?: string) => {
|
||||
if (!dateStr) return "未使用"
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
className="py-6 space-y-6 max-w-4xl mx-auto px-4"
|
||||
>
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">访问令牌</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 border-b pb-5">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent">个人访问令牌 (AccessToken)</h1>
|
||||
<p className="text-sm text-muted-foreground">管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
variant={'secondary'}
|
||||
>
|
||||
<Plus className="mr-1.5 size-4" />
|
||||
生成新令牌
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 安全警告提示 */}
|
||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 flex gap-3 text-amber-600 text-xs leading-relaxed">
|
||||
<AlertTriangle className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold">安全提示:</span>
|
||||
<p className="text-muted-foreground">
|
||||
访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<CardTitle className="text-base font-semibold">活动令牌列表</CardTitle>
|
||||
<CardDescription className="text-xs">当前可用的所有访问令牌</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{accessTokensQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
) : (accessTokensQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{(accessTokensQuery.data ?? []).map((token) => (
|
||||
<div
|
||||
key={token.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">{token.name}</span>
|
||||
{token.is_admin ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-rose-500/40 text-rose-500 bg-rose-500/5 font-semibold">
|
||||
<Shield className="size-2.5 mr-0.5" />
|
||||
管理员
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 border-border/50 text-muted-foreground bg-muted/10 font-semibold">
|
||||
用户令牌
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
<div className="font-mono bg-muted/30 px-2 py-0.5 rounded border border-border/50 w-fit select-all">
|
||||
{token.masked_token}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 pt-1">
|
||||
<span>创建于: {formatDate(token.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 sm:self-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleCopyText(token.masked_token, token.id)}
|
||||
>
|
||||
{copiedId === token.id ? (
|
||||
<Check className="size-3.5 mr-1 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5 mr-1" />
|
||||
)}
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/5 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleRotateToken(token.id, token.name)}
|
||||
disabled={rotateTokenMutation.isPending}
|
||||
>
|
||||
<RefreshCw className={`size-3.5 mr-1 ${rotateTokenMutation.isPending && rotateTokenMutation.variables === token.id ? 'animate-spin' : ''}`} />
|
||||
轮换
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 hover:border-rose-500/20 rounded-lg h-8 px-2.5"
|
||||
onClick={() => handleDeleteToken(token.id, token.name)}
|
||||
disabled={deleteTokenMutation.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-10 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<Key className="size-8 text-muted-foreground/30" />
|
||||
<span>您当前暂无生成任何访问令牌</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-dashed"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
生成第一个令牌
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 创建令牌 Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form onSubmit={handleCreateToken}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">生成新令牌</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
请为新的访问令牌设置一个易于识别的名称,以便将来管理。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token-name" className="text-xs font-semibold">令牌名称</Label>
|
||||
<Input
|
||||
id="token-name"
|
||||
placeholder="例如:my-development-key"
|
||||
value={tokenName}
|
||||
onChange={(e) => setTokenName(e.target.value)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl border border-dashed focus:border-indigo-500 focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
{user?.is_admin && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 bg-muted/5">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="token-admin" className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Shield className="size-3.5 text-rose-500" />
|
||||
管理员权限
|
||||
</Label>
|
||||
<p className="text-[11px] text-muted-foreground leading-normal">
|
||||
开启后此令牌可访问 /admin/** 管理端点,默认关闭
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="token-admin"
|
||||
checked={tokenIsAdmin}
|
||||
onCheckedChange={setTokenIsAdmin}
|
||||
disabled={createTokenMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCreateDialogOpen(false)}
|
||||
disabled={createTokenMutation.isPending}
|
||||
className="rounded-xl text-xs h-9 border-dashed"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createTokenMutation.isPending}
|
||||
variant={'secondary'}
|
||||
>
|
||||
{createTokenMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
正在生成...
|
||||
</>
|
||||
) : (
|
||||
"生成令牌"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 明文 Token 显示 Dialog (仅显示一次) */}
|
||||
<Dialog open={viewDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setNewCreatedToken(null)
|
||||
setViewDialogOpen(false)
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold text-foreground flex items-center gap-1.5">
|
||||
<Check className="size-5 text-emerald-500 border border-emerald-500 rounded-full p-0.5" />
|
||||
令牌密钥已就绪
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{newCreatedToken && (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 明文 Token 文本框 */}
|
||||
<div className="flex items-center gap-2 rounded-xl bg-indigo-500/5 border border-dashed border-indigo-500/30 p-3">
|
||||
<span className="font-mono text-xs select-all break-all flex-1 text-indigo-600 font-semibold leading-relaxed">
|
||||
{newCreatedToken.token}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-8 rounded-lg shrink-0 border-dashed text-indigo-500 hover:bg-indigo-500/10 hover:border-indigo-500/30 transition-colors"
|
||||
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
|
||||
>
|
||||
{copiedId === 9999 ? (
|
||||
<Check className="size-3.5 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 强提示 */}
|
||||
<div className="rounded-xl border border-rose-500/20 bg-rose-500/5 p-4 flex gap-3 text-rose-600 text-xs leading-relaxed">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<span className="font-bold">重要提示:</span>
|
||||
<p className="text-muted-foreground">
|
||||
这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewCreatedToken(null)
|
||||
setViewDialogOpen(false)
|
||||
}}
|
||||
className="rounded-xl w-full"
|
||||
>
|
||||
我已经复制并妥善保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {Check, Loader2, Monitor, Moon, Sun} from "lucide-react"
|
||||
import {useTheme} from "next-themes"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {useCustomTheme} from "@/lib/theme"
|
||||
import {cn} from "@/lib/utils"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb"
|
||||
|
||||
function ThemeModeSection() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const modes = [
|
||||
{ id: "light", name: "明亮", icon: Sun },
|
||||
{ id: "dark", name: "黑暗", icon: Moon },
|
||||
{ id: "system", name: "自动", icon: Monitor }
|
||||
]
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon
|
||||
return (
|
||||
<Button
|
||||
key={mode.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{Icon && <Icon className="size-3 mr-1" />}
|
||||
{mode.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择应用程序的整体明暗主题
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{modes.map((mode) => {
|
||||
const Icon = mode.icon
|
||||
const isActive = theme === mode.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={mode.id}
|
||||
variant={isActive ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setTheme(mode.id)}
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{Icon && <Icon className="size-3 mr-1" />}
|
||||
{mode.name}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InterfaceAppearanceSection() {
|
||||
const { themes, currentThemeId, setTheme: setCustomTheme, isLoading } = useCustomTheme()
|
||||
const { theme: mode } = useTheme()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
const [switching, setSwitching] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleSetTheme = (themeId: string) => {
|
||||
if (switching === themeId) return
|
||||
setSwitching(themeId)
|
||||
try {
|
||||
setCustomTheme(themeId)
|
||||
const themeName = themes.find(t => t.id === themeId)?.name || themeId
|
||||
toast.success(`外观已切换为 ${ themeName }`)
|
||||
} catch {
|
||||
toast.error("切换失败")
|
||||
} finally {
|
||||
setSwitching(null)
|
||||
}
|
||||
}
|
||||
|
||||
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
选择界面的配色方案和视觉风格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
|
||||
{themes.map((theme) => {
|
||||
const isActive = currentThemeId === theme.id || (!currentThemeId && theme.id === "default.css")
|
||||
const isSwitching = switching === theme.id
|
||||
const colors = isDark ? theme.colors.dark : theme.colors.light
|
||||
|
||||
return (
|
||||
<div
|
||||
key={theme.id}
|
||||
onClick={() => handleSetTheme(theme.id)}
|
||||
className={cn(
|
||||
"group relative cursor-pointer rounded-xl border transition-all overflow-hidden",
|
||||
isActive
|
||||
? "border-primary ring-2 ring-primary/20"
|
||||
: "border-border hover:border-border/50 hover:shadow-md"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="aspect-[4/3] w-full relative"
|
||||
style={{ backgroundColor: colors.background || colors['background'] }}
|
||||
>
|
||||
<div className="flex h-full p-2 gap-2">
|
||||
<div
|
||||
className="w-1/4 h-full rounded-sm opacity-80"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
<div className="flex-1 space-y-1.5 pt-1">
|
||||
<div
|
||||
className="h-1.5 w-1/3 rounded-full opacity-90"
|
||||
style={{ backgroundColor: colors.primary || colors['primary'] }}
|
||||
/>
|
||||
<div
|
||||
className="h-8 w-full rounded-sm opacity-40"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
<div
|
||||
className="h-4 w-1/2 rounded-sm opacity-30"
|
||||
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && !isSwitching && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-primary text-primary-foreground rounded-full p-0.5 shadow-sm">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-2 border-t border-border/50 bg-card">
|
||||
<div className={cn("text-[10px] font-medium text-center truncate transition-colors", isActive ? "text-primary" : "text-muted-foreground")}>
|
||||
{theme.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceMain() {
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<ThemeModeSection />
|
||||
|
||||
<InterfaceAppearanceSection />
|
||||
|
||||
<div className="space-y-4 opacity-50 pointer-events-none grayscale">
|
||||
<h2 className="font-medium text-sm text-muted-foreground">即将推出</h2>
|
||||
<div className="bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50">
|
||||
<span className="text-sm text-muted-foreground">更多自定义选项正在开发中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import services from "@/lib/services"
|
||||
import type {AuthSource, AuthSourceRequest} from "@/lib/services/admin"
|
||||
|
||||
const emptyForm: AuthSourceRequest = {
|
||||
name: "",
|
||||
type: "oidc",
|
||||
display_name: "",
|
||||
is_active: false,
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
openid_discovery_url: "",
|
||||
scopes: "openid profile email",
|
||||
icon_url: "",
|
||||
}
|
||||
|
||||
export function AuthSourceModal({
|
||||
isOpen,
|
||||
source,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
source: AuthSource | null
|
||||
onClose: () => void
|
||||
onChanged: () => Promise<void>
|
||||
}) {
|
||||
const [form, setForm] = useState<AuthSourceRequest>(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (source) {
|
||||
setForm({
|
||||
name: source.name,
|
||||
type: source.type,
|
||||
display_name: source.display_name,
|
||||
is_active: source.is_active,
|
||||
client_id: source.client_id,
|
||||
client_secret: "",
|
||||
openid_discovery_url: source.openid_discovery_url,
|
||||
scopes: source.scopes || "openid profile email",
|
||||
icon_url: source.icon_url,
|
||||
})
|
||||
} else {
|
||||
setForm(emptyForm)
|
||||
}
|
||||
} else {
|
||||
setForm(emptyForm)
|
||||
setSaving(false)
|
||||
}
|
||||
}, [isOpen, source])
|
||||
|
||||
const saveSource = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (source) {
|
||||
await services.adminAuthSource.updateAuthSource(source.id, form)
|
||||
} else {
|
||||
await services.adminAuthSource.createAuthSource(form)
|
||||
}
|
||||
await onChanged()
|
||||
toast.success("认证源已保存")
|
||||
onClose()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存认证源失败")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{source ? "编辑认证源" : "新增认证源"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{source ? "修改系统自定义的 OIDC 认证源参数。" : "配置新的自定义 OIDC 认证源。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 pt-2">
|
||||
<div className="space-y-2">
|
||||
<Label>标识符 (英文名称)</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
disabled={!!source}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如: github"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>展示名称</Label>
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
|
||||
placeholder="例如: GitHub 登录"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client ID</Label>
|
||||
<Input
|
||||
value={form.client_id}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Client Secret</Label>
|
||||
<Input
|
||||
value={form.client_secret}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, client_secret: e.target.value }))}
|
||||
placeholder={source ? "留空则保留原值" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>Discovery URL (OIDC 发行方 URL)</Label>
|
||||
<Input
|
||||
value={form.openid_discovery_url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Scopes</Label>
|
||||
<Input
|
||||
value={form.scopes}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>图标 URL (可选)</Label>
|
||||
<Input
|
||||
value={form.icon_url}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, icon_url: e.target.value }))}
|
||||
placeholder="https://... 或留空"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10">
|
||||
<div>
|
||||
<div className="font-medium text-sm">启用认证源</div>
|
||||
<div className="text-xs text-muted-foreground">启用后会立即显示在登录页和账号绑定中。</div>
|
||||
</div>
|
||||
<Switch checked={form.is_active} onCheckedChange={(checked) => setForm((prev) => ({ ...prev, is_active: checked }))} />
|
||||
</div>
|
||||
<div className="md:col-span-2 flex justify-end gap-2 pt-2 border-t mt-2">
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={saveSource} disabled={saving} variant="secondary">
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import {useMutation, useQuery} from "@tanstack/react-query"
|
||||
import {ExternalLink, RefreshCw, Sparkles} from "lucide-react"
|
||||
import {toast} from "sonner"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-dashed py-2 last:border-b-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-right text-xs font-medium text-foreground break-all">{value || "-"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InfoTab() {
|
||||
const updateQuery = useQuery({
|
||||
queryKey: ["admin", "update"],
|
||||
queryFn: () => services.adminStatus.getUpdateStatus(),
|
||||
refetchInterval: 30 * 60 * 1000,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const applyUpdateMutation = useMutation({
|
||||
mutationFn: () => services.adminStatus.applyUpdate(),
|
||||
onSuccess: () => {
|
||||
toast.success("升级包已校验完成,服务正在重启")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "应用升级失败")
|
||||
},
|
||||
})
|
||||
|
||||
const update = updateQuery.data
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-muted p-1.5 text-muted-foreground">
|
||||
<Sparkles className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">应用更新</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
检查上游 GitHub Actions Release 并升级当前服务
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
{update?.update_available ? (
|
||||
<Badge>发现新版本</Badge>
|
||||
) : update ? (
|
||||
<Badge variant="secondary">已是最新</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 pt-4">
|
||||
{updateQuery.isLoading ? (
|
||||
<div className="flex min-h-32 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : updateQuery.isError ? (
|
||||
<div className="flex min-h-32 flex-col items-center justify-center gap-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateQuery.error.message || "无法获取上游版本信息"}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => updateQuery.refetch()}>
|
||||
<RefreshCw data-icon="inline-start" />
|
||||
重新检查
|
||||
</Button>
|
||||
</div>
|
||||
) : update ? (
|
||||
<>
|
||||
<div>
|
||||
<InfoRow label="当前版本" value={update.current_version} />
|
||||
<InfoRow label="最新版本" value={update.latest_version} />
|
||||
{update.build_time && <InfoRow label="构建时间" value={update.build_time} />}
|
||||
<InfoRow label="运行平台" value={update.platform} />
|
||||
<InfoRow label="上游仓库" value={update.upstream_repository} />
|
||||
<InfoRow label="Release 资产" value={update.asset_name} />
|
||||
</div>
|
||||
|
||||
{update.release_notes && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-dashed p-3">
|
||||
<p className="text-xs font-medium">更新说明</p>
|
||||
<p className="max-h-40 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground">
|
||||
{update.release_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => updateQuery.refetch()} disabled={updateQuery.isFetching}>
|
||||
{updateQuery.isFetching ? <Spinner data-icon="inline-start" /> : <RefreshCw data-icon="inline-start" />}
|
||||
检查更新
|
||||
</Button>
|
||||
{update.release_url && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={update.release_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink data-icon="inline-start" />
|
||||
查看 Release
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button type="button" size="sm" disabled={!update.can_upgrade || applyUpdateMutation.isPending}>
|
||||
{applyUpdateMutation.isPending && <Spinner data-icon="inline-start" />}
|
||||
立即升级
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>升级到 {update.latest_version}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => applyUpdateMutation.mutate()}>
|
||||
确认升级
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{!update.can_upgrade && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{update.current_version === "dev"
|
||||
? "开发构建没有可比较的 Release 版本,不能执行自动升级。"
|
||||
: update.update_available
|
||||
? "当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。"
|
||||
: "当前版本无需升级。"}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import {Bell} from "lucide-react"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {useNotificationSettings} from "@/contexts/notification-settings-context"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb"
|
||||
|
||||
export function NotificationsMain() {
|
||||
const { showBell, setShowBell } = useNotificationSettings()
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
设置通知相关的显示选项
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="size-5 text-primary" />
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="show-bell" className="text-sm font-medium cursor-pointer">
|
||||
显示通知铃铛
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
在顶部导航栏中显示通知铃铛图标
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="show-bell"
|
||||
checked={showBell}
|
||||
onCheckedChange={setShowBell}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client"
|
||||
|
||||
import {useMemo} from "react"
|
||||
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import {KeyRound, ShieldAlert, X} from "lucide-react"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {TemplatesManager} from "./templates"
|
||||
import {toast} from "sonner"
|
||||
|
||||
interface OperationTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
}
|
||||
|
||||
export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const uploadTypesQuery = useQuery({
|
||||
queryKey: ["admin", "upload-types"],
|
||||
queryFn: () => services.adminSystemConfig.listUploadTypes(),
|
||||
})
|
||||
|
||||
const updateWhitelistMutation = useMutation({
|
||||
mutationFn: async (newValue: string) => {
|
||||
const config = configs["file_access_whitelist"]
|
||||
if (!config) {
|
||||
throw new Error("缺少配置项: file_access_whitelist")
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig("file_access_whitelist", {
|
||||
value: newValue,
|
||||
description: config.description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("文件访问白名单已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新白名单失败")
|
||||
},
|
||||
})
|
||||
|
||||
const whitelistConfig = configs["file_access_whitelist"]
|
||||
const currentWhitelist = useMemo<string[]>(() => {
|
||||
if (!whitelistConfig?.value) return ["avatar"]
|
||||
try {
|
||||
const parsed = JSON.parse(whitelistConfig.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch {
|
||||
// 降级支持逗号分隔解析
|
||||
return whitelistConfig.value.split(",").map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
return ["avatar"]
|
||||
}, [whitelistConfig?.value])
|
||||
|
||||
const handleAddType = (type: string) => {
|
||||
if (!type || currentWhitelist.includes(type)) return
|
||||
const newWhitelist = [...currentWhitelist, type]
|
||||
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
|
||||
}
|
||||
|
||||
const handleRemoveType = (typeToRemove: string) => {
|
||||
const newWhitelist = currentWhitelist.filter(t => t !== typeToRemove)
|
||||
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
|
||||
}
|
||||
|
||||
const availableTypes = useMemo(() => {
|
||||
const types = uploadTypesQuery.data ?? []
|
||||
return types.map(t => {
|
||||
let label = t
|
||||
if (t === "avatar") label = "头像 (avatar)"
|
||||
else if (t === "attachment") label = "附件 (attachment)"
|
||||
else if (t === "doc") label = "文档 (doc)"
|
||||
else if (t === "generic") label = "通用 (generic)"
|
||||
return { value: t, label }
|
||||
})
|
||||
}, [uploadTypesQuery.data])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* 文件访问白名单设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<KeyRound className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">文件访问权限控制</CardTitle>
|
||||
<CardDescription className="text-xs">配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">添加免鉴权类型:</span>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={handleAddType}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending || uploadTypesQuery.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]" size="sm">
|
||||
<SelectValue placeholder="选择业务类型..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes
|
||||
.filter(t => !currentWhitelist.includes(t.value))
|
||||
.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{availableTypes.filter(t => !currentWhitelist.includes(t.value)).length === 0 && (
|
||||
<div className="text-xs text-muted-foreground p-2 text-center">所有类型已添加</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 当前白名单列表 */}
|
||||
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-indigo-500" />
|
||||
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
|
||||
</div>
|
||||
|
||||
{currentWhitelist.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentWhitelist.map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="secondary"
|
||||
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 dark:bg-indigo-500/20 border border-indigo-500/20"
|
||||
>
|
||||
{availableTypes.find(t => t.value === type)?.label || type}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveType(type)}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
|
||||
className="rounded-full outline-hidden hover:bg-indigo-500/20 p-0.5 text-indigo-600 dark:text-indigo-400 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
白名单已空,所有类型文件的访问都将需要登录。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 通知模板管理 */}
|
||||
<TemplatesManager />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import {ComponentType, useMemo} from "react"
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query"
|
||||
import {
|
||||
Bell,
|
||||
Code,
|
||||
CreditCard,
|
||||
Database,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Home,
|
||||
Info,
|
||||
Layers,
|
||||
LayoutList,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Terminal,
|
||||
UserRound
|
||||
} from "lucide-react"
|
||||
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
interface MenuItem {
|
||||
path: string
|
||||
label: string
|
||||
description: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
interface MenuGroup {
|
||||
name: string
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
const MENU_GROUPS: MenuGroup[] = [
|
||||
{
|
||||
name: "基础菜单",
|
||||
items: [
|
||||
{ path: "/home", label: "首页", description: "系统控制台/个人首页", icon: Home },
|
||||
{ path: "/files", label: "我的文件", description: "用户个人文件管理与上传", icon: FolderOpen },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "管理菜单",
|
||||
items: [
|
||||
{ path: "/admin/users", label: "用户管理", description: "查看和管理系统用户列表及其状态", icon: UserRound },
|
||||
{ path: "/admin/tasks", label: "任务管理", description: "查看和调度系统异步及定时任务", icon: Layers },
|
||||
{ path: "/admin/files", label: "存储管理", description: "管理系统文件存储和清理无用文件", icon: FolderOpen },
|
||||
{ path: "/admin/database", label: "数据管理", description: "监控物理数据库状态、分页浏览表数据及交互式 SQL 查询", icon: Database },
|
||||
{ path: "/admin/push", label: "通知推送", description: "配置和发送系统通知及推送消息", icon: Bell },
|
||||
{ path: "/admin/logs", label: "系统日志", description: "查看异步任务执行日志和系统运行情况", icon: Terminal },
|
||||
{ path: "/admin/system", label: "系统配置", description: "管理和维护系统基础键值对配置", icon: ShieldCheck },
|
||||
{ path: "/admin/settings", label: "系统设置", description: "配置安全验证、邮箱服务及目录显示", icon: Settings, readOnly: true },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "文档菜单",
|
||||
items: [
|
||||
{ path: "/admin/demo", label: "规范示例", description: "内置 UI 组件与设计规范的展示、调试与参考", icon: Code },
|
||||
{ path: "/docs/api", label: "接口文档", description: "系统 Swagger 交互式 API 接口文档", icon: CreditCard },
|
||||
{ path: "/docs/how-to-use", label: "使用文档", description: "面向开发与运营的部署使用指南", icon: FileText },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
interface OtherTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
}
|
||||
|
||||
export function OtherTab({ configs }: OtherTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const menuDisplayConfig = useMemo(() => {
|
||||
const raw = configs["menu_display_config"]?.value
|
||||
if (!raw) return {} as Record<string, boolean>
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, boolean>
|
||||
} catch {
|
||||
return {} as Record<string, boolean>
|
||||
}
|
||||
}, [configs])
|
||||
|
||||
const updateMenuConfigMutation = useMutation({
|
||||
mutationFn: async ({ path, enabled }: { path: string; enabled: boolean }) => {
|
||||
const newConfig = { ...menuDisplayConfig, [path]: enabled }
|
||||
const currentCfg = configs["menu_display_config"]
|
||||
await services.adminSystemConfig.updateSystemConfig("menu_display_config", {
|
||||
value: JSON.stringify(newConfig),
|
||||
description: currentCfg?.description || "目录显示配置(JSON 字符串,格式为 {url: enabled})",
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("目录显示配置已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleMenuToggle = (path: string, checked: boolean) => {
|
||||
updateMenuConfigMutation.mutate({ path, enabled: checked })
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<LayoutList className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">目录显示管理</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-6">
|
||||
{MENU_GROUPS.map((group) => (
|
||||
<div key={group.name} className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground tracking-wider uppercase">
|
||||
{group.name}
|
||||
</span>
|
||||
<div className="h-px bg-border/40 flex-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isReadOnly = !!item.readOnly
|
||||
const checked = menuDisplayConfig[item.path] !== false
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5 flex-1 min-w-0 pr-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-indigo-500 shrink-0" />}
|
||||
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
|
||||
{isReadOnly && (
|
||||
<span className="text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0">
|
||||
不可隐藏
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-normal line-clamp-2">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={isReadOnly || updateMenuConfigMutation.isPending}
|
||||
onCheckedChange={(val) => handleMenuToggle(item.path, val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-3.5 rounded-lg border border-dashed border-indigo-500/20 bg-indigo-500/5 flex items-start gap-2.5">
|
||||
<Info className="size-4 text-indigo-500 shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-semibold text-foreground">安全提示:</span>
|
||||
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {motion, useAnimation} from "motion/react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import {useUser} from "@/contexts/user-context"
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Camera,
|
||||
Edit,
|
||||
Globe,
|
||||
Info,
|
||||
Link2,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
Shield,
|
||||
Unlink,
|
||||
User as UserIcon
|
||||
} from "lucide-react"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Separator} from "@/components/ui/separator"
|
||||
import {Textarea} from "@/components/ui/textarea"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import type {ChangePasswordRequest, UpdateProfileRequest} from "@/lib/services/auth"
|
||||
import {AuthService} from "@/lib/services/auth"
|
||||
import services from "@/lib/services"
|
||||
import {ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset} from "@/components/ui/image-crop"
|
||||
import {toast} from "sonner"
|
||||
|
||||
export function ProfileMain() {
|
||||
const { user, loading, refetch } = useUser()
|
||||
const controls = useAnimation()
|
||||
const isAnimatingRef = React.useRef(false)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 修改密码 State
|
||||
const [oldPassword, setOldPassword] = React.useState("")
|
||||
const [newPassword, setNewPassword] = React.useState("")
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("")
|
||||
|
||||
// 编辑个人资料 State
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
|
||||
const [nickname, setNickname] = React.useState("")
|
||||
const [email, setEmail] = React.useState("")
|
||||
const [bio, setBio] = React.useState("")
|
||||
const [phone, setPhone] = React.useState("")
|
||||
const [gender, setGender] = React.useState("secret")
|
||||
const [website, setWebsite] = React.useState("")
|
||||
const [location, setLocation] = React.useState("")
|
||||
const [avatarUrl, setAvatarUrl] = React.useState("")
|
||||
|
||||
// 头像裁剪 State
|
||||
const [cropFile, setCropFile] = React.useState<File | null>(null)
|
||||
const [isCropDialogOpen, setIsCropDialogOpen] = React.useState(false)
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
// 初始化编辑表单数据
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
setNickname(user.nickname || "")
|
||||
setEmail(user.email || "")
|
||||
setBio(user.bio || "")
|
||||
setPhone(user.phone || "")
|
||||
setGender(user.gender || "secret")
|
||||
setWebsite(user.website || "")
|
||||
setLocation(user.location || "")
|
||||
setAvatarUrl(user.avatar_url || "")
|
||||
}
|
||||
}, [user, isEditDialogOpen])
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: (req: ChangePasswordRequest) => AuthService.changePassword(req),
|
||||
onSuccess: () => {
|
||||
toast.success("密码修改成功")
|
||||
setOldPassword("")
|
||||
setNewPassword("")
|
||||
setConfirmPassword("")
|
||||
void refetch()
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "修改密码失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
const handlePasswordChange = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error("两次输入的新密码不一致")
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error("新密码长度不能少于 8 位")
|
||||
return
|
||||
}
|
||||
changePasswordMutation.mutate({
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
}
|
||||
|
||||
const updateProfileMutation = useMutation({
|
||||
mutationFn: (req: UpdateProfileRequest) => AuthService.updateProfile(req),
|
||||
onSuccess: () => {
|
||||
toast.success("个人信息修改成功")
|
||||
setIsEditDialogOpen(false)
|
||||
void refetch()
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "修改个人信息失败,请重试")
|
||||
},
|
||||
})
|
||||
|
||||
const handleProfileSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
updateProfileMutation.mutate({
|
||||
nickname,
|
||||
email,
|
||||
avatar_url: avatarUrl,
|
||||
bio,
|
||||
phone,
|
||||
gender,
|
||||
website,
|
||||
location,
|
||||
})
|
||||
}
|
||||
|
||||
const externalAccountBindingsQuery = useQuery({
|
||||
queryKey: ["auth", "external-accounts"],
|
||||
queryFn: () => AuthService.getExternalAccountBindings(),
|
||||
})
|
||||
|
||||
const publicAuthSourcesQuery = useQuery({
|
||||
queryKey: ["auth", "public-sources"],
|
||||
queryFn: () => AuthService.getAuthSources(),
|
||||
})
|
||||
|
||||
const bindSourceMutation = useMutation({
|
||||
mutationFn: async (sourceName: string) => {
|
||||
const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName, "bind")
|
||||
sessionStorage.setItem("redirect_after_login", `${window.location.pathname}${window.location.search}`)
|
||||
window.location.href = authorize_url
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "绑定认证源失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
if (isAnimatingRef.current) return
|
||||
|
||||
isAnimatingRef.current = true
|
||||
controls.start({
|
||||
rotate: [0, -20, 20, -20, 20, 0],
|
||||
transition: { duration: 0.5, ease: "easeInOut" }
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimatingRef.current = false
|
||||
}, 650)
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setCropFile(file)
|
||||
setIsCropDialogOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCroppedImage = async (croppedBase64: string) => {
|
||||
try {
|
||||
const res = await services.upload.uploadBase64Image(croppedBase64, "avatar", "avatar.png")
|
||||
setAvatarUrl(`/f/${res.id}`)
|
||||
setIsCropDialogOpen(false)
|
||||
setCropFile(null)
|
||||
toast.success("头像上传成功,点击保存以生效")
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || "上传头像失败")
|
||||
}
|
||||
}
|
||||
|
||||
const getGenderLabel = (g?: string) => {
|
||||
switch (g) {
|
||||
case "male": return "男"
|
||||
case "female": return "女"
|
||||
case "secret": return "保密"
|
||||
default: return g || "保密"
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-6 space-y-4 max-w-4xl mx-auto">
|
||||
<div className="border-b border-border pb-4">
|
||||
<h1 className="text-2xl font-semibold">个人资料</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="py-6 space-y-6 max-w-4xl mx-auto">
|
||||
<div className="text-sm text-muted-foreground">未找到用户信息</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-6 space-y-6 max-w-4xl mx-auto">
|
||||
<div className="font-semibold">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/settings" className="text-base text-primary">设置</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="text-base font-semibold">个人资料</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
{/* 基本资料面板 */}
|
||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
|
||||
<div className="border-b pb-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">基本资料</h2>
|
||||
<p className="text-xs text-muted-foreground">您的个人账户基本信息</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-dashed"
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
>
|
||||
<Edit className="size-3.5 mr-1.5" />
|
||||
编辑资料
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 pt-2">
|
||||
<motion.div
|
||||
animate={controls}
|
||||
onClick={handleAvatarClick}
|
||||
className="cursor-pointer origin-center shrink-0"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<Avatar className="size-20 md:size-24 border-2 border-primary/10 shadow-md">
|
||||
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
|
||||
<AvatarFallback className="text-2xl bg-indigo-600 text-white font-bold">
|
||||
{(user.nickname || user.username).slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex-1 w-full space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<UserIcon className="size-3 text-muted-foreground/70" />
|
||||
账户
|
||||
</div>
|
||||
<div className="text-sm font-semibold">@{user.username}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<UserIcon className="size-3 text-muted-foreground/70" />
|
||||
昵称
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{user.nickname || '未设置'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Info className="size-3 text-muted-foreground/70" />
|
||||
用户ID (UID)
|
||||
</div>
|
||||
<div className="text-sm font-mono font-semibold">{user.id}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Mail className="size-3 text-muted-foreground/70" />
|
||||
邮箱
|
||||
</div>
|
||||
<div className="text-sm font-semibold truncate max-w-[220px]">{user.email || '未绑定'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Phone className="size-3 text-muted-foreground/70" />
|
||||
手机号码
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{user.phone || '未设置'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<UserIcon className="size-3 text-muted-foreground/70" />
|
||||
性别
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{getGenderLabel(user.gender)}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Globe className="size-3 text-muted-foreground/70" />
|
||||
个人网站
|
||||
</div>
|
||||
<div className="text-sm font-semibold truncate max-w-[220px]">
|
||||
{user.website ? (
|
||||
<a
|
||||
href={user.website.startsWith("http") ? user.website : `http://${user.website}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:underline"
|
||||
>
|
||||
{user.website}
|
||||
</a>
|
||||
) : (
|
||||
'未设置'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<MapPin className="size-3 text-muted-foreground/70" />
|
||||
所在地
|
||||
</div>
|
||||
<div className="text-sm font-semibold">{user.location || '未设置'}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<Shield className="size-3 text-muted-foreground/70" />
|
||||
管理员身份
|
||||
</div>
|
||||
<div className="text-sm font-semibold flex items-center gap-1">
|
||||
{user.is_admin ? (
|
||||
<span className="text-rose-600 flex items-center gap-1">
|
||||
<Shield className="size-3.5" />
|
||||
是
|
||||
</span>
|
||||
) : (
|
||||
<span>否</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="border-dashed" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
|
||||
<BookOpen className="size-3 text-muted-foreground/70" />
|
||||
个人简介
|
||||
</div>
|
||||
<div className="text-sm text-foreground/80 leading-relaxed max-w-2xl bg-muted/20 border border-dashed rounded-lg p-3">
|
||||
{user.bio || '这个人很懒,什么都没有留下。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 下方左右并排布局容器 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 修改密码面板 */}
|
||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="border-b pb-4 flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<Lock className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold tracking-tight">修改密码</h2>
|
||||
<p className="text-[11px] text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.need_change_password && (
|
||||
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-2.5 text-xs text-amber-500 flex items-start gap-2.5">
|
||||
<Info className="size-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-semibold">密码风险提示</p>
|
||||
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
|
||||
为了账号安全,您必须修改初始密码。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-3 pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入当前密码"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">新密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="新密码(至少 8 位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">确认新密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="w-full sm:w-auto h-8 text-xs"
|
||||
disabled={changePasswordMutation.isPending}
|
||||
>
|
||||
{changePasswordMutation.isPending ? "提交中..." : "确认修改"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账号绑定面板 */}
|
||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="border-b pb-4 flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Link2 className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold tracking-tight">第三方账号绑定</h2>
|
||||
<p className="text-[11px] text-muted-foreground">管理并关联您的第三方授权账户,便于快捷登录与验证</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已绑定账号列表 */}
|
||||
<div className="space-y-2 pt-4">
|
||||
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
|
||||
{externalAccountBindingsQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
) : (externalAccountBindingsQuery.data ?? []).length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{(externalAccountBindingsQuery.data ?? []).map((binding) => (
|
||||
<div
|
||||
key={binding.id}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-3 bg-card hover:bg-muted/10 transition-all duration-300"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<span className="font-semibold text-xs text-foreground block">{binding.auth_source_label}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono block truncate max-w-[150px]">
|
||||
{binding.external_username || binding.email || "未提供账号标识"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[11px] text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg h-7 px-2 transition-colors"
|
||||
onClick={async () => {
|
||||
await AuthService.deleteExternalAccountBinding(binding.id)
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "external-accounts"] })
|
||||
toast.success("绑定已移除")
|
||||
}}
|
||||
>
|
||||
<Unlink className="size-3 mr-1" />
|
||||
解绑
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-6 text-center text-[11px] text-muted-foreground bg-muted/5 flex flex-col items-center justify-center">
|
||||
<Link2 className="size-5 text-muted-foreground/30 mb-1" />
|
||||
暂无绑定的第三方账号
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="border-dashed my-4" />
|
||||
|
||||
{/* 绑定新账号列表 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
|
||||
{publicAuthSourcesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||
</div>
|
||||
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{(publicAuthSourcesQuery.data ?? []).map((source) => (
|
||||
<Button
|
||||
key={source.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-8"
|
||||
onClick={() => {
|
||||
void bindSourceMutation.mutateAsync(source.name)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="size-3 text-muted-foreground group-hover:text-indigo-500" />
|
||||
<span>绑定 {source.display_name || source.name}</span>
|
||||
</div>
|
||||
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[11px] text-muted-foreground text-center py-2">
|
||||
暂无可用第三方认证源
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑资料 Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑个人资料</DialogTitle>
|
||||
<DialogDescription>修改您的基本信息,保存后立即生效。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||
{/* 上传头像域 */}
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<div
|
||||
className="group relative cursor-pointer rounded-full border border-dashed hover:border-primary/50 transition-all"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Avatar className="size-20 border-2 border-primary/5 shadow-md">
|
||||
<AvatarImage src={avatarUrl} alt={nickname} />
|
||||
<AvatarFallback className="text-xl bg-indigo-600 text-white font-bold">
|
||||
{(nickname || "U").slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Camera className="size-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">点击上传/修改头像</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="nickname" className="text-xs">昵称</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="请输入您的昵称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="email" className="text-xs">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="请输入您的邮箱"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="phone" className="text-xs">手机号码</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="gender" className="text-xs">性别</Label>
|
||||
<Select value={gender} onValueChange={setGender}>
|
||||
<SelectTrigger id="gender" size="sm" className="w-full text-xs h-8">
|
||||
<SelectValue placeholder="选择性别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">男</SelectItem>
|
||||
<SelectItem value="female">女</SelectItem>
|
||||
<SelectItem value="secret">保密</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="website" className="text-xs">个人网站</Label>
|
||||
<Input
|
||||
id="website"
|
||||
value={website}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="location" className="text-xs">所在地</Label>
|
||||
<Input
|
||||
id="location"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
className="h-8 text-xs rounded-lg"
|
||||
placeholder="如:北京"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="bio" className="text-xs">个人简介</Label>
|
||||
<Textarea
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
className="text-xs min-h-[60px] rounded-lg"
|
||||
placeholder="介绍一下自己吧..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => setIsEditDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
>
|
||||
{updateProfileMutation.isPending ? "保存中..." : "保存修改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 头像裁剪 Dialog */}
|
||||
<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>裁剪头像</DialogTitle>
|
||||
<DialogDescription>调整头像位置和大小,使其处于居中圆形区域内。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{cropFile && (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
<ImageCrop file={cropFile} aspect={1} onCrop={handleCroppedImage}>
|
||||
<ImageCropContent className="max-w-sm rounded-lg border border-dashed" />
|
||||
<div className="mt-3 flex justify-center gap-2">
|
||||
<ImageCropReset />
|
||||
<ImageCropApply />
|
||||
</div>
|
||||
</ImageCrop>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => {
|
||||
setIsCropDialogOpen(false)
|
||||
setCropFile(null)
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import {
|
||||
Clock,
|
||||
Fingerprint,
|
||||
Globe,
|
||||
Loader2,
|
||||
Lock,
|
||||
Mail,
|
||||
Pencil,
|
||||
Plus,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserPlus
|
||||
} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {AuthSourceModal} from "@/components/common/settings/auth-source-modal"
|
||||
import services from "@/lib/services"
|
||||
import type {AuthSource, SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
const SECURITY_KEYS = [
|
||||
{
|
||||
key: "password_login_enabled",
|
||||
title: "允许密码登录",
|
||||
description: "关闭后仅保留第三方 OIDC 认证源进行系统登录。",
|
||||
icon: Lock,
|
||||
},
|
||||
{
|
||||
key: "registration_enabled",
|
||||
title: "允许注册",
|
||||
description: "关闭后系统将禁止新用户进行自主账号注册。",
|
||||
icon: UserPlus,
|
||||
},
|
||||
{
|
||||
key: "password_register_enabled",
|
||||
title: "允许密码注册",
|
||||
description: "关闭后只能通过管理员创建或第三方认证关联建号。",
|
||||
icon: Fingerprint,
|
||||
},
|
||||
{
|
||||
key: "oidc_login_enabled",
|
||||
title: "允许 OIDC 登录",
|
||||
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
|
||||
icon: Globe,
|
||||
},
|
||||
{
|
||||
key: "email_login_verification_enabled",
|
||||
title: "邮箱登录验证",
|
||||
description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。",
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
key: "email_register_verification_enabled",
|
||||
title: "邮箱注册验证",
|
||||
description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。",
|
||||
icon: Mail,
|
||||
},
|
||||
] as const
|
||||
|
||||
interface SecurityTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
}
|
||||
|
||||
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false)
|
||||
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null)
|
||||
|
||||
const [capCount, setCapCount] = useState("")
|
||||
const [capDifficulty, setCapDifficulty] = useState("")
|
||||
const [capSize, setCapSize] = useState("")
|
||||
const [capTTL, setCapTTL] = useState("")
|
||||
const [capTokenTTL, setCapTokenTTL] = useState("")
|
||||
const [capAutoSolve, setCapAutoSolve] = useState(true)
|
||||
|
||||
const [sessionTTL, setSessionTTL] = useState("168")
|
||||
const [customHours, setCustomHours] = useState("")
|
||||
|
||||
const authSourcesQuery = useQuery({
|
||||
queryKey: ["auth", "sources"],
|
||||
queryFn: () => services.adminAuthSource.listAuthSources(),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (systemConfigsQuery.data) {
|
||||
const cfgMap = configs
|
||||
setCapCount(cfgMap["cap_challenge_count"]?.value || "1")
|
||||
setCapDifficulty(cfgMap["cap_challenge_difficulty"]?.value || "4")
|
||||
setCapSize(cfgMap["cap_challenge_size"]?.value || "32")
|
||||
setCapTTL(cfgMap["cap_challenge_ttl_seconds"]?.value || "600")
|
||||
setCapTokenTTL(cfgMap["cap_token_ttl_seconds"]?.value || "1200")
|
||||
setCapAutoSolve(cfgMap["cap_auto_solve"]?.value !== "false")
|
||||
|
||||
// 初始化登录保持设置
|
||||
const ttlVal = cfgMap["login_session_ttl_hours"]?.value || "0"
|
||||
if (ttlVal === "0" || ttlVal === "168" || ttlVal === "720" || ttlVal === "-1") {
|
||||
setSessionTTL(ttlVal)
|
||||
setCustomHours("")
|
||||
} else {
|
||||
setSessionTTL("custom")
|
||||
setCustomHours(ttlVal)
|
||||
}
|
||||
}
|
||||
}, [systemConfigsQuery.data, configs])
|
||||
|
||||
const updateTTLMutation = useMutation({
|
||||
mutationFn: async (value: string) => {
|
||||
const config = configs["login_session_ttl_hours"]
|
||||
if (!config) {
|
||||
throw new Error("缺少配置项: login_session_ttl_hours")
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig("login_session_ttl_hours", {
|
||||
value: value,
|
||||
description: config.description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("登录状态保持时间已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleTTLChange = (val: string) => {
|
||||
setSessionTTL(val)
|
||||
if (val !== "custom") {
|
||||
updateTTLMutation.mutate(val)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomBlur = () => {
|
||||
const parsed = parseInt(customHours, 10)
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
toast.error("请输入有效的过期小时数(正整数)")
|
||||
// 重置为原本的值
|
||||
const originalVal = configs["login_session_ttl_hours"]?.value || "0"
|
||||
setCustomHours(originalVal === "custom" || ["0", "168", "720", "-1"].includes(originalVal) ? "" : originalVal)
|
||||
return
|
||||
}
|
||||
updateTTLMutation.mutate(parsed.toString())
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
|
||||
const config = configs[key]
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(key, {
|
||||
value: value ? "true" : "false",
|
||||
description: config.description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("系统安全配置已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const toggleSourceMutation = useMutation({
|
||||
mutationFn: async (source: AuthSource) => {
|
||||
await services.adminAuthSource.toggleAuthSource(source.id, { is_active: !source.is_active })
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
toast.success("认证源状态已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "切换状态失败")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteSourceMutation = useMutation({
|
||||
mutationFn: async (sourceId: string) => {
|
||||
await services.adminAuthSource.deleteAuthSource(sourceId)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
toast.success("认证源已删除")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除认证源失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggle = (key: string, checked: boolean) => {
|
||||
updateConfigMutation.mutate({ key, value: checked })
|
||||
}
|
||||
|
||||
const saveCapMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = [
|
||||
{ key: "cap_challenge_count", value: capCount },
|
||||
{ key: "cap_challenge_difficulty", value: capDifficulty },
|
||||
{ key: "cap_challenge_size", value: capSize },
|
||||
{ key: "cap_challenge_ttl_seconds", value: capTTL },
|
||||
{ key: "cap_token_ttl_seconds", value: capTokenTTL },
|
||||
{ key: "cap_auto_solve", value: capAutoSolve ? "true" : "false" },
|
||||
]
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
await services.adminSystemConfig.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("人机验证配置已成功保存")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleCapSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveCapMutation.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 系统登录与注册控制 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Settings className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">系统登录/注册设置</CardTitle>
|
||||
<CardDescription className="text-xs">配置系统的登录限制与用户自主注册权限</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{SECURITY_KEYS.map((item) => {
|
||||
const config = configs[item.key]
|
||||
const checked = config ? config.value === "true" : false
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="size-4 text-indigo-500" />}
|
||||
<span className="font-medium text-sm text-foreground">{item.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">{item.description}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onCheckedChange={(value) => handleToggle(item.key, value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 登录状态保持时间 (选择后立即更改) */}
|
||||
<div
|
||||
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm md:col-span-2"
|
||||
>
|
||||
<div className="space-y-1 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="size-4 text-indigo-500" />
|
||||
<span className="font-medium text-sm text-foreground">登录状态保持时间</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
|
||||
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Select
|
||||
value={sessionTTL}
|
||||
disabled={updateTTLMutation.isPending}
|
||||
onValueChange={handleTTLChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] bg-card border-dashed text-xs h-8">
|
||||
<SelectValue placeholder="选择保留时间" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">关闭 (浏览器关闭自动退登)</SelectItem>
|
||||
<SelectItem value="168">7 天</SelectItem>
|
||||
<SelectItem value="720">30 天</SelectItem>
|
||||
<SelectItem value="-1">永不过期</SelectItem>
|
||||
<SelectItem value="custom">自定义时长</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{sessionTTL === "custom" && (
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={customHours}
|
||||
onChange={(e) => setCustomHours(e.target.value)}
|
||||
onBlur={handleCustomBlur}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleCustomBlur()
|
||||
}
|
||||
}}
|
||||
placeholder="小时"
|
||||
disabled={updateTTLMutation.isPending}
|
||||
className="w-20 bg-card border-dashed text-xs h-8 px-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 认证源配置管理 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Globe className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">认证源管理</CardTitle>
|
||||
<CardDescription className="text-xs">添加、修改并启用系统自定义的 OIDC 认证源</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedSource(null)
|
||||
setAuthSourceModalOpen(true)
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增认证源
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
{authSourcesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (authSourcesQuery.data ?? []).length > 0 ? (
|
||||
(authSourcesQuery.data ?? []).map((source) => (
|
||||
<div
|
||||
key={source.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm text-foreground">{source.display_name || source.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
source.is_active
|
||||
? "bg-emerald-500/10 text-emerald-500 border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{source.is_active ? "已启用" : "已禁用"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
标识符: {source.name} · 类型: {source.type.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`text-xs px-2.5 py-1 rounded-lg border font-medium hidden sm:inline-block ${
|
||||
source.client_secret_configured
|
||||
? "bg-indigo-500/5 text-indigo-500 border-indigo-500/10"
|
||||
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
|
||||
}`}>
|
||||
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={source.is_active}
|
||||
disabled={toggleSourceMutation.isPending}
|
||||
className="scale-90 mr-2"
|
||||
onCheckedChange={() => toggleSourceMutation.mutate(source)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedSource(source)
|
||||
setAuthSourceModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
disabled={deleteSourceMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除认证源「${source.display_name || source.name}」吗?`)) {
|
||||
deleteSourceMutation.mutate(source.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<span>暂无配置的认证源,点击上方按钮新增</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedSource(null)
|
||||
setAuthSourceModalOpen(true)
|
||||
}}
|
||||
className="border-dashed"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增认证源
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 人机验证配置 (Cap CAPTCHA) */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Shield className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">人机验证配置 (Cap CAPTCHA)</CardTitle>
|
||||
<CardDescription className="text-xs">配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={configs["cap_login_enabled"]?.value === "true"}
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onCheckedChange={(checked) => handleToggle("cap_login_enabled", checked)}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{/* 自动开始计算 Switch */}
|
||||
<div className="flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-semibold">打开页面后自动开始计算</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={capAutoSolve}
|
||||
onCheckedChange={setCapAutoSolve}
|
||||
/>
|
||||
</div>
|
||||
<form onSubmit={handleCapSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_count" className="text-xs font-semibold">难题数量 (Count)</Label>
|
||||
<Input
|
||||
id="cap_challenge_count"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={capCount}
|
||||
onChange={(e) => setCapCount(e.target.value)}
|
||||
placeholder="50"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">客户端需求解的难题总数。默认 1,推荐 1 至 5</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_difficulty" className="text-xs font-semibold">验证难度 (Difficulty)</Label>
|
||||
<Input
|
||||
id="cap_challenge_difficulty"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={capDifficulty}
|
||||
onChange={(e) => setCapDifficulty(e.target.value)}
|
||||
placeholder="4"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_size" className="text-xs font-semibold">盐值长度 (Size)</Label>
|
||||
<Input
|
||||
id="cap_challenge_size"
|
||||
type="number"
|
||||
min={8}
|
||||
max={64}
|
||||
value={capSize}
|
||||
onChange={(e) => setCapSize(e.target.value)}
|
||||
placeholder="32"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">难题盐值混淆字符长度。默认 32</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cap_challenge_ttl" className="text-xs font-semibold">难题超时时长 (秒)</Label>
|
||||
<Input
|
||||
id="cap_challenge_ttl"
|
||||
type="number"
|
||||
min={10}
|
||||
value={capTTL}
|
||||
onChange={(e) => setCapTTL(e.target.value)}
|
||||
placeholder="600"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">难题有效期限。默认 600 秒 (10 分钟)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="cap_token_ttl" className="text-xs font-semibold">验证凭证有效时长 (秒)</Label>
|
||||
<Input
|
||||
id="cap_token_ttl"
|
||||
type="number"
|
||||
min={10}
|
||||
value={capTokenTTL}
|
||||
onChange={(e) => setCapTokenTTL(e.target.value)}
|
||||
placeholder="1200"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4 border-t border-dashed">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveCapMutation.isPending}
|
||||
>
|
||||
{saveCapMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AuthSourceModal
|
||||
isOpen={authSourceModalOpen}
|
||||
source={selectedSource}
|
||||
onClose={() => setAuthSourceModalOpen(false)}
|
||||
onChanged={async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
|
||||
await authSourcesQuery.refetch()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
"use client"
|
||||
|
||||
import {useEffect, useState} from "react"
|
||||
import {useMutation, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
|
||||
import {Globe, Loader2, Mail, Search, Server, Sparkles} 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 {Switch} from "@/components/ui/switch"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import services from "@/lib/services"
|
||||
import type {SystemConfig} from "@/lib/services/admin"
|
||||
import {toast} from "sonner"
|
||||
|
||||
interface SystemTabProps {
|
||||
configs: Record<string, SystemConfig>
|
||||
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
|
||||
}
|
||||
|
||||
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [serverAddress, setServerAddress] = useState("")
|
||||
const [siteName, setSiteName] = useState("")
|
||||
const [smtpHost, setSmtpHost] = useState("")
|
||||
const [smtpPort, setSmtpPort] = useState("")
|
||||
const [smtpUsername, setSmtpUsername] = useState("")
|
||||
const [smtpPassword, setSmtpPassword] = useState("")
|
||||
const [smtpTestOpen, setSmtpTestOpen] = useState(false)
|
||||
const [smtpTestTo, setSmtpTestTo] = useState("")
|
||||
const [smtpTestLog, setSmtpTestLog] = useState("")
|
||||
const [smtpTestSuccess, setSmtpTestSuccess] = useState<boolean | null>(null)
|
||||
const [smtpTestError, setSmtpTestError] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (systemConfigsQuery.data) {
|
||||
setServerAddress(configs["server_address"]?.value || "")
|
||||
setSiteName(configs["site_name"]?.value || "")
|
||||
setSmtpHost(configs["smtp_host"]?.value || "")
|
||||
setSmtpPort(configs["smtp_port"]?.value || "587")
|
||||
setSmtpUsername(configs["smtp_username"]?.value || "")
|
||||
setSmtpPassword(configs["smtp_password"]?.value || "")
|
||||
}
|
||||
}, [systemConfigsQuery.data, configs])
|
||||
|
||||
const handleDetectAddress = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
setServerAddress(window.location.origin)
|
||||
toast.success("已自动获取当前域名并填充")
|
||||
}
|
||||
}
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
|
||||
const config = configs[key]
|
||||
if (!config) {
|
||||
throw new Error(`缺少配置项: ${key}`)
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(key, {
|
||||
value: value ? "true" : "false",
|
||||
description: config.description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||
toast.success("配置已更新")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "更新配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const saveSystemMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const currentAddrCfg = configs["server_address"]
|
||||
const currentSiteCfg = configs["site_name"]
|
||||
await Promise.all([
|
||||
services.adminSystemConfig.updateSystemConfig("server_address", {
|
||||
value: serverAddress,
|
||||
description: currentAddrCfg?.description || "服务器地址",
|
||||
}),
|
||||
services.adminSystemConfig.updateSystemConfig("site_name", {
|
||||
value: siteName,
|
||||
description: currentSiteCfg?.description || "系统平台的展示名称",
|
||||
}),
|
||||
])
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["public-config"] }),
|
||||
])
|
||||
toast.success("通用配置已成功保存")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSystemSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveSystemMutation.mutate()
|
||||
}
|
||||
|
||||
const saveSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const updates = [
|
||||
{ key: "smtp_host", value: smtpHost },
|
||||
{ key: "smtp_port", value: smtpPort },
|
||||
{ key: "smtp_username", value: smtpUsername },
|
||||
{ key: "smtp_password", value: smtpPassword },
|
||||
]
|
||||
|
||||
for (const update of updates) {
|
||||
const currentCfg = configs[update.key]
|
||||
if (update.key === "smtp_password" && (update.value === "" || update.value === "******")) {
|
||||
// If already configured and sent empty or mask, skip updating it (keep existing)
|
||||
if (currentCfg && currentCfg.value === "******") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
await services.adminSystemConfig.updateSystemConfig(update.key, {
|
||||
value: update.value,
|
||||
description: currentCfg?.description || "",
|
||||
})
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||
toast.success("SMTP 邮件配置已成功保存")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "保存配置失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSmtpSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
saveSmtpMutation.mutate()
|
||||
}
|
||||
|
||||
const testSmtpMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
setSmtpTestLog("正在发起连接测试...\n")
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
|
||||
const res = await services.adminSystemConfig.testSMTP({
|
||||
smtp_host: smtpHost,
|
||||
smtp_port: parseInt(smtpPort, 10) || 587,
|
||||
smtp_username: smtpUsername,
|
||||
smtp_password: smtpPassword,
|
||||
to: smtpTestTo,
|
||||
})
|
||||
return res
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setSmtpTestLog(data.log)
|
||||
if (data.success) {
|
||||
setSmtpTestSuccess(true)
|
||||
toast.success("测试邮件发送成功")
|
||||
} else {
|
||||
setSmtpTestSuccess(false)
|
||||
setSmtpTestError(data.error || "发送失败,请检查配置和日志。")
|
||||
toast.error("测试邮件发送失败")
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setSmtpTestSuccess(false)
|
||||
setSmtpTestError(error.message || "请求发送失败")
|
||||
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`)
|
||||
toast.error(error.message || "测试请求发送失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleSmtpTestSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!smtpTestTo) {
|
||||
toast.error("请输入目标邮箱地址")
|
||||
return
|
||||
}
|
||||
testSmtpMutation.mutate()
|
||||
}
|
||||
|
||||
const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true"
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* 通用设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-indigo-500/10 p-1.5 text-indigo-500">
|
||||
<Server className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">通用设置</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
配置站点标识、服务地址与搜索引擎收录策略
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSystemSave} className="flex flex-col gap-6">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="site_name" className="text-xs font-semibold">
|
||||
站点名称
|
||||
</Label>
|
||||
<Input
|
||||
id="site_name"
|
||||
type="text"
|
||||
value={siteName}
|
||||
onChange={(e) => setSiteName(e.target.value)}
|
||||
placeholder="例如: Wavelet"
|
||||
className="border-dashed bg-card text-xs"
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
用于网页标题、登录注册页面、管理后台与系统邮件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor="server_address" className="text-xs font-semibold">
|
||||
服务器访问地址
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={handleDetectAddress}
|
||||
className="h-auto px-0 text-xs"
|
||||
>
|
||||
<Sparkles data-icon="inline-start" />
|
||||
使用当前域名
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
id="server_address"
|
||||
type="url"
|
||||
value={serverAddress}
|
||||
onChange={(e) => setServerAddress(e.target.value)}
|
||||
placeholder="例如: https://example.com"
|
||||
className="border-dashed bg-card text-xs"
|
||||
/>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
限定 API 的允许来源;留空将开放任意来源访问。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="rounded-md bg-muted p-1.5 text-muted-foreground">
|
||||
<Search className="size-4" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-semibold">搜索引擎抓取与收录</span>
|
||||
<Badge variant={indexingEnabled ? "default" : "secondary"}>
|
||||
{indexingEnabled ? "允许收录" : "禁止收录"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
aria-label="允许搜索引擎抓取与收录"
|
||||
checked={indexingEnabled}
|
||||
disabled={updateConfigMutation.isPending || systemConfigsQuery.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-dashed pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Globe className="size-3.5" />
|
||||
地址变更会影响跨域访问策略,请填写完整协议与域名。
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveSystemMutation.isPending}
|
||||
>
|
||||
{saveSystemMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 data-icon="inline-start" className="animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SMTP 邮件设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">SMTP 邮件设置</CardTitle>
|
||||
<CardDescription className="text-xs">配置系统的邮件发送服务 (SMTP)</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSmtpSave} className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_host" className="text-xs font-semibold">SMTP 服务器地址</Label>
|
||||
<Input
|
||||
id="smtp_host"
|
||||
type="text"
|
||||
value={smtpHost}
|
||||
onChange={(e) => setSmtpHost(e.target.value)}
|
||||
placeholder="例如: smtp.example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_port" className="text-xs font-semibold">SMTP 端口</Label>
|
||||
<Input
|
||||
id="smtp_port"
|
||||
type="number"
|
||||
value={smtpPort}
|
||||
onChange={(e) => setSmtpPort(e.target.value)}
|
||||
placeholder="例如: 587 或 465"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_username" className="text-xs font-semibold">SMTP 账户</Label>
|
||||
<Input
|
||||
id="smtp_username"
|
||||
type="text"
|
||||
value={smtpUsername}
|
||||
onChange={(e) => setSmtpUsername(e.target.value)}
|
||||
placeholder="例如: sender@example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_password" className="text-xs font-semibold">SMTP 访问凭证</Label>
|
||||
<Input
|
||||
id="smtp_password"
|
||||
type="password"
|
||||
value={smtpPassword}
|
||||
onChange={(e) => setSmtpPassword(e.target.value)}
|
||||
placeholder={configs["smtp_password"]?.value === "******" ? "•••••• (已配置,留空或输入新值)" : "输入凭证密码"}
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t border-dashed">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSmtpTestOpen(true)
|
||||
setSmtpTestTo("")
|
||||
setSmtpTestLog("")
|
||||
setSmtpTestSuccess(null)
|
||||
setSmtpTestError("")
|
||||
}}
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
测试发件
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveSmtpMutation.isPending}
|
||||
>
|
||||
{saveSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
|
||||
<DialogContent className="max-w-lg border border-dashed">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">SMTP 发件测试</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSmtpTestSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="smtp_test_to" className="text-xs font-semibold">目标邮箱地址</Label>
|
||||
<Input
|
||||
id="smtp_test_to"
|
||||
type="email"
|
||||
required
|
||||
value={smtpTestTo}
|
||||
onChange={(e) => setSmtpTestTo(e.target.value)}
|
||||
placeholder="例如: receiver@example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
disabled={testSmtpMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{smtpTestLog && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">连接与传输日志</Label>
|
||||
<pre className="bg-zinc-950 text-zinc-50 font-mono p-4 rounded-lg text-[10px] h-60 overflow-y-auto whitespace-pre-wrap border border-dashed border-zinc-800 leading-relaxed">
|
||||
{smtpTestLog}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpTestSuccess === true && (
|
||||
<div className="p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs">
|
||||
测试成功!邮件已顺利发出。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{smtpTestSuccess === false && (
|
||||
<div className="p-3 rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 text-rose-500 text-xs break-all">
|
||||
测试失败:{smtpTestError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSmtpTestOpen(false)}
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={testSmtpMutation.isPending}
|
||||
>
|
||||
{testSmtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
测试中...
|
||||
</>
|
||||
) : (
|
||||
"开始测试"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react"
|
||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||
import {FileText, Loader2, Pencil, Plus, Trash2} 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 {Textarea} from "@/components/ui/textarea"
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||
import services from "@/lib/services"
|
||||
import type {Template} from "@/lib/services/admin/types"
|
||||
import {toast} from "sonner"
|
||||
|
||||
export function TemplatesManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null)
|
||||
|
||||
// Form states
|
||||
const [key, setKey] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [type, setType] = useState("email")
|
||||
const [subject, setSubject] = useState("")
|
||||
const [content, setContent] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
|
||||
const templatesQuery = useQuery({
|
||||
queryKey: ["admin", "templates"],
|
||||
queryFn: () => services.adminTemplate.listTemplates(),
|
||||
})
|
||||
|
||||
const createTemplateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await services.adminTemplate.createTemplate({
|
||||
key,
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
content,
|
||||
description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已成功创建")
|
||||
setModalOpen(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "创建模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await services.adminTemplate.updateTemplate(key, {
|
||||
name,
|
||||
type,
|
||||
subject,
|
||||
content,
|
||||
description,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已成功保存")
|
||||
setModalOpen(false)
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "修改模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const deleteTemplateMutation = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
await services.adminTemplate.deleteTemplate(key)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
|
||||
toast.success("通知模板已删除")
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "删除模板失败")
|
||||
},
|
||||
})
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setSelectedTemplate(null)
|
||||
setKey("")
|
||||
setName("")
|
||||
setType("email")
|
||||
setSubject("")
|
||||
setContent("")
|
||||
setDescription("")
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleOpenEdit = (tmpl: Template) => {
|
||||
setSelectedTemplate(tmpl)
|
||||
setKey(tmpl.key)
|
||||
setName(tmpl.name)
|
||||
setType(tmpl.type)
|
||||
setSubject(tmpl.subject || "")
|
||||
setContent(tmpl.content)
|
||||
setDescription(tmpl.description || "")
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (selectedTemplate) {
|
||||
updateTemplateMutation.mutate(selectedTemplate.key)
|
||||
} else {
|
||||
createTemplateMutation.mutate()
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createTemplateMutation.isPending || updateTemplateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4 flex flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<FileText className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">通知模板管理</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleOpenCreate}
|
||||
variant="secondary"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增模板
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
{templatesQuery.isPending ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground/50" />
|
||||
</div>
|
||||
) : (templatesQuery.data ?? []).length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{(templatesQuery.data ?? []).map((tmpl) => (
|
||||
<div
|
||||
key={tmpl.id}
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-sm text-foreground">{tmpl.name}</span>
|
||||
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
|
||||
tmpl.is_system
|
||||
? "bg-indigo-500/10 text-indigo-500 border-indigo-500/20"
|
||||
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
}`}>
|
||||
{tmpl.is_system ? "系统内置" : "自定义"}
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-border/50 bg-muted/50 text-muted-foreground font-mono">
|
||||
{tmpl.type.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
标识符: <span className="font-mono text-indigo-500 bg-indigo-500/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
|
||||
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
|
||||
</div>
|
||||
{tmpl.description && (
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed max-w-xl">
|
||||
{tmpl.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-indigo-500 hover:bg-indigo-500/10 rounded-lg transition-colors"
|
||||
onClick={() => handleOpenEdit(tmpl)}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
disabled={tmpl.is_system || deleteTemplateMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除模板「${tmpl.name}」吗?`)) {
|
||||
deleteTemplateMutation.mutate(tmpl.key)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3">
|
||||
<span>暂无配置的通知模板,点击上方按钮新增</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleOpenCreate}
|
||||
className="border-dashed"
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" />
|
||||
新增模板
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent className="max-w-xl border border-dashed">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{selectedTemplate ? "编辑通知模板" : "新增通知模板"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `{"{{.Code}}"}` 表示验证码变量)。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_key" className="text-xs font-semibold">
|
||||
模板标识符 (Key)
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_key"
|
||||
type="text"
|
||||
required
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
placeholder="例如: login_email"
|
||||
className="bg-card border-dashed text-xs"
|
||||
disabled={!!selectedTemplate}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
模板的唯一标识,在代码中通过此 Key 调用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_name" className="text-xs font-semibold">
|
||||
模板名称
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_name"
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如: 登录验证码邮件"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
用于后台识别该模板的可读名称。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_type" className="text-xs font-semibold">
|
||||
模板类型
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_type"
|
||||
type="text"
|
||||
required
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
placeholder="email"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
模板分类标识,目前支持 `email`。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_subject" className="text-xs font-semibold">
|
||||
模板主题 (Subject)
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="例如: Wavelet 登录验证码"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
邮件标题(类型为 email 时生效)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_description" className="text-xs font-semibold">
|
||||
模板说明与变量描述
|
||||
</Label>
|
||||
<Input
|
||||
id="tmpl_description"
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="例如: 包含变量:{{.Code}},5分钟内有效"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="tmpl_content" className="text-xs font-semibold">
|
||||
模板正文内容 (Content)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="tmpl_content"
|
||||
required
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>"
|
||||
rows={8}
|
||||
className="bg-card border-dashed text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setModalOpen(false)}
|
||||
disabled={isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user