This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
@@ -1,17 +1,34 @@
"use client"
'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 * 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 { 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,
@@ -19,7 +36,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
} from '@/components/ui/dialog';
import {
Breadcrumb,
BreadcrumbItem,
@@ -27,240 +44,283 @@ import {
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import {UserService} from "@/lib/services/user"
import type {CreateTokenResponse} from "@/lib/services/user"
import {useAuth} from "@/components/providers/auth-provider"
import {toast} from "sonner"
} 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)
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"],
queryKey: ['user', 'access-tokens'],
queryFn: () => UserService.getAccessTokens(),
})
});
// 创建 Token
const createTokenMutation = useMutation({
mutationFn: ({ name, isAdmin }: { name: string; isAdmin: boolean }) => UserService.createAccessToken(name, isAdmin),
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("访问令牌创建成功")
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 || "创建访问令牌失败")
toast.error(error.message || '创建访问令牌失败');
},
})
});
// 删除 Token
const deleteTokenMutation = useMutation({
mutationFn: (id: number) => UserService.deleteAccessToken(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] })
toast.success("访问令牌已撤销")
void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'],
});
toast.success('访问令牌已撤销');
},
onError: (error: Error) => {
toast.error(error.message || "删除访问令牌失败")
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("访问令牌轮换成功,旧密钥已失效")
setNewCreatedToken(data);
setViewDialogOpen(true);
void queryClient.invalidateQueries({
queryKey: ['user', 'access-tokens'],
});
toast.success('访问令牌轮换成功,旧密钥已失效');
},
onError: (error: Error) => {
toast.error(error.message || "轮换访问令牌失败")
toast.error(error.message || '轮换访问令牌失败');
},
})
});
const handleCreateToken = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!tokenName.trim()) {
toast.error("请输入令牌名称")
return
toast.error('请输入令牌名称');
return;
}
createTokenMutation.mutate({ name: tokenName.trim(), isAdmin: tokenIsAdmin })
}
createTokenMutation.mutate({
name: tokenName.trim(),
isAdmin: tokenIsAdmin,
});
};
const handleDeleteToken = (id: number, name: string) => {
if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) {
deleteTokenMutation.mutate(id)
if (
window.confirm(
`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`,
)
) {
deleteTokenMutation.mutate(id);
}
}
};
const handleRotateToken = (id: number, name: string) => {
if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) {
rotateTokenMutation.mutate(id)
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)
await navigator.clipboard.writeText(text);
setCopiedId(id);
toast.success('复制成功');
setTimeout(() => setCopiedId(null), 2000);
} catch {
toast.error("复制失败")
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",
})
}
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"
transition={{ duration: 0.35, ease: 'easeOut' }}
className='py-6 space-y-6 max-w-4xl mx-auto px-4'
>
<div className="font-semibold">
<div className='font-semibold'>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
<Link href='/settings' className='text-base text-primary'>
设置
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">访问令牌</BreadcrumbPage>
<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 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>
<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"
type='button'
onClick={() => setCreateDialogOpen(true)}
variant={'secondary'}
>
<Plus className="mr-1.5 size-4" />
<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">
<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>
<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">
<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-primary" />
<div className='flex items-center justify-center py-8'>
<Loader2 className='size-6 animate-spin text-primary' />
</div>
) : (accessTokensQuery.data ?? []).length > 0 ? (
<div className="space-y-3">
<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"
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>
<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
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
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">
<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">
<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">
<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-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
onClick={() => handleCopyText(token.masked_token, token.id)}
type='button'
variant='outline'
size='sm'
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/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" />
<Check className='size-3.5 mr-1 text-emerald-500' />
) : (
<Copy className="size-3.5 mr-1" />
<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-primary hover:bg-primary/5 rounded-lg h-8 px-2.5"
type='button'
variant='outline'
size='sm'
className='text-xs border-dashed text-muted-foreground hover:text-primary hover:bg-primary/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' : ''}`} />
<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"
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" />
<Trash2 className='size-3.5 mr-1' />
撤销
</Button>
</div>
@@ -268,17 +328,17 @@ export function AccessTokenMain() {
))}
</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" />
<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"
type='button'
variant='outline'
size='sm'
className='border-dashed'
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="mr-1 size-3.5" />
<Plus className='mr-1 size-3.5' />
生成第一个令牌
</Button>
</div>
@@ -288,39 +348,46 @@ export function AccessTokenMain() {
{/* 创建令牌 Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className='sm:max-w-[425px]'>
<form onSubmit={handleCreateToken}>
<DialogHeader>
<DialogTitle className="text-base font-semibold">生成新令牌</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
<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>
<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"
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-primary focus:ring-0 focus-visible:ring-0"
className='rounded-xl border border-dashed focus:border-primary 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" />
<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">
<p className='text-[11px] text-muted-foreground leading-normal'>
开启后此令牌可访问 /admin/** 管理端点,默认关闭
</p>
</div>
<Switch
id="token-admin"
id='token-admin'
checked={tokenIsAdmin}
onCheckedChange={setTokenIsAdmin}
disabled={createTokenMutation.isPending}
@@ -328,28 +395,28 @@ export function AccessTokenMain() {
</div>
)}
</div>
<DialogFooter className="gap-2">
<DialogFooter className='gap-2'>
<Button
type="button"
variant="outline"
type='button'
variant='outline'
onClick={() => setCreateDialogOpen(false)}
disabled={createTokenMutation.isPending}
className="rounded-xl text-xs h-9 border-dashed"
className='rounded-xl text-xs h-9 border-dashed'
>
取消
</Button>
<Button
type="submit"
type='submit'
disabled={createTokenMutation.isPending}
variant={'secondary'}
>
{createTokenMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
正在生成...
</>
) : (
"生成令牌"
'生成令牌'
)}
</Button>
</DialogFooter>
@@ -358,48 +425,51 @@ export function AccessTokenMain() {
</Dialog>
{/* 明文 Token 显示 Dialog (仅显示一次) */}
<Dialog open={viewDialogOpen} onOpenChange={(open) => {
if (!open) {
setNewCreatedToken(null)
setViewDialogOpen(false)
}
}}>
<DialogContent className="sm:max-w-[500px]">
<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 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">
<div className='space-y-4 py-2'>
{/* 明文 Token 文本框 */}
<div className="flex items-center gap-2 rounded-xl bg-primary/5 border border-dashed border-primary/30 p-3">
<span className="font-mono text-xs select-all break-all flex-1 text-primary font-semibold leading-relaxed">
<div className='flex items-center gap-2 rounded-xl bg-primary/5 border border-dashed border-primary/30 p-3'>
<span className='font-mono text-xs select-all break-all flex-1 text-primary font-semibold leading-relaxed'>
{newCreatedToken.token}
</span>
<Button
type="button"
size="icon"
variant="outline"
className="size-8 rounded-lg shrink-0 border-dashed text-primary hover:bg-primary/10 hover:border-primary/30 transition-colors"
type='button'
size='icon'
variant='outline'
className='size-8 rounded-lg shrink-0 border-dashed text-primary hover:bg-primary/10 hover:border-primary/30 transition-colors'
onClick={() => handleCopyText(newCreatedToken.token, 9999)}
>
{copiedId === 9999 ? (
<Check className="size-3.5 text-emerald-500" />
<Check className='size-3.5 text-emerald-500' />
) : (
<Copy className="size-3.5" />
<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">
<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>
@@ -409,12 +479,12 @@ export function AccessTokenMain() {
<DialogFooter>
<Button
type="button"
type='button'
onClick={() => {
setNewCreatedToken(null)
setViewDialogOpen(false)
setNewCreatedToken(null);
setViewDialogOpen(false);
}}
className="rounded-xl w-full"
className='rounded-xl w-full'
>
我已经复制并妥善保存
</Button>
@@ -422,5 +492,5 @@ export function AccessTokenMain() {
</DialogContent>
</Dialog>
</motion.div>
)
);
}
@@ -1,209 +1,251 @@
"use client"
'use client';
import * as React from "react"
import Link from "next/link"
import { Moon, Sun, Loader2, Check, Monitor } 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"
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)
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true)
}, [])
setMounted(true);
}, []);
const modes = [
{ id: "light", name: "明亮", icon: Sun },
{ id: "dark", name: "黑暗", icon: Moon },
{ id: "system", name: "自动", icon: Monitor }
]
{ id: 'light', name: '明亮', icon: Sun },
{ id: 'dark', name: '黑暗', icon: Moon },
{ id: 'system', name: '自动', icon: Monitor },
];
if (!mounted) {
return (
<div className="space-y-6">
<div className='space-y-6'>
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
<h2 className='font-medium text-sm text-foreground'>主题模式</h2>
<p className='text-xs text-muted-foreground'>
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
<div className='flex gap-2'>
{modes.map((mode) => {
const Icon = mode.icon
const Icon = mode.icon;
return (
<Button
key={mode.id}
variant="secondary"
size="sm"
className="flex-1 text-xs"
variant='secondary'
size='sm'
className='flex-1 text-xs'
>
{Icon && <Icon className="size-3 mr-1" />}
{Icon && <Icon className='size-3 mr-1' />}
{mode.name}
</Button>
)
);
})}
</div>
</div>
)
);
}
return (
<div className="space-y-6">
<div className='space-y-6'>
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
<h2 className='font-medium text-sm text-foreground'>主题模式</h2>
<p className='text-xs text-muted-foreground'>
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
<div className='flex gap-2'>
{modes.map((mode) => {
const Icon = mode.icon
const isActive = theme === mode.id
const Icon = mode.icon;
const isActive = theme === mode.id;
return (
<Button
key={mode.id}
variant={isActive ? "default" : "secondary"}
size="sm"
variant={isActive ? 'default' : 'secondary'}
size='sm'
onClick={() => setTheme(mode.id)}
className="flex-1 text-xs"
className='flex-1 text-xs'
>
{Icon && <Icon className="size-3 mr-1" />}
{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)
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)
}, [])
setMounted(true);
}, []);
const handleSetTheme = (themeId: string) => {
if (switching === themeId) return
setSwitching(themeId)
if (switching === themeId) return;
setSwitching(themeId);
try {
setCustomTheme(themeId)
const themeName = themes.find(t => t.id === themeId)?.name || themeId
toast.success(`外观已切换为 ${ themeName }`)
setCustomTheme(themeId);
const themeName = themes.find((t) => t.id === themeId)?.name || themeId;
toast.success(`外观已切换为 ${themeName}`);
} catch {
toast.error("切换失败")
toast.error('切换失败');
} finally {
setSwitching(null)
setSwitching(null);
}
}
};
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
const isDark =
mounted &&
(mode === 'dark' ||
(mode === 'system' &&
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches));
return (
<div className="space-y-6">
<div className='space-y-6'>
<div>
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
<p className="text-xs text-muted-foreground">
<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 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">
<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
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",
'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"
? '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'] }}
className='aspect-[4/3] w-full relative'
style={{
backgroundColor:
colors.background || colors['background'],
}}
>
<div className="flex h-full p-2 gap-2">
<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'] }}
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='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'] }}
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'] }}
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'] }}
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 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")}>
<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">
<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>
<Link href='/settings' className='text-base text-primary'>
设置
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
<BreadcrumbPage className='text-base font-semibold'>
外观设置
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
@@ -213,12 +255,14 @@ export function AppearanceMain() {
<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 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>
)
);
}
@@ -1,26 +1,32 @@
"use client"
'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"
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: "",
name: '',
type: 'oidc',
display_name: '',
is_active: false,
client_id: "",
client_secret: "",
openid_discovery_url: "",
scopes: "openid profile email",
icon_url: "",
}
client_id: '',
client_secret: '',
openid_discovery_url: '',
scopes: 'openid profile email',
icon_url: '',
};
export function AuthSourceModal({
isOpen,
@@ -28,13 +34,13 @@ export function AuthSourceModal({
onClose,
onChanged,
}: {
isOpen: boolean
source: AuthSource | null
onClose: () => void
onChanged: () => Promise<void>
isOpen: boolean;
source: AuthSource | null;
onClose: () => void;
onChanged: () => Promise<void>;
}) {
const [form, setForm] = useState<AuthSourceRequest>(emptyForm)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState<AuthSourceRequest>(emptyForm);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
@@ -45,122 +51,152 @@ export function AuthSourceModal({
display_name: source.display_name,
is_active: source.is_active,
client_id: source.client_id,
client_secret: "",
client_secret: '',
openid_discovery_url: source.openid_discovery_url,
scopes: source.scopes || "openid profile email",
scopes: source.scopes || 'openid profile email',
icon_url: source.icon_url,
})
});
} else {
setForm(emptyForm)
setForm(emptyForm);
}
} else {
setForm(emptyForm)
setSaving(false)
setForm(emptyForm);
setSaving(false);
}
}, [isOpen, source])
}, [isOpen, source]);
const saveSource = async () => {
setSaving(true)
setSaving(true);
try {
if (source) {
await services.adminAuthSource.updateAuthSource(source.id, form)
await services.adminAuthSource.updateAuthSource(source.id, form);
} else {
await services.adminAuthSource.createAuthSource(form)
await services.adminAuthSource.createAuthSource(form);
}
await onChanged()
toast.success("认证源已保存")
onClose()
await onChanged();
toast.success('认证源已保存');
onClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存认证源失败")
toast.error(error instanceof Error ? error.message : '保存认证源失败');
} finally {
setSaving(false)
setSaving(false);
}
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-xl">
<DialogContent className='max-w-xl'>
<DialogHeader>
<DialogTitle>{source ? "编辑认证源" : "新增认证源"}</DialogTitle>
<DialogTitle>{source ? '编辑认证源' : '新增认证源'}</DialogTitle>
<DialogDescription>
{source ? "修改系统自定义的 OIDC 认证源参数。" : "配置新的自定义 OIDC 认证源。"}
{source
? '修改系统自定义的 OIDC 认证源参数。'
: '配置新的自定义 OIDC 认证源。'}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2 pt-2">
<div className="space-y-2">
<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"
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder='例如: github'
/>
</div>
<div className="space-y-2">
<div className='space-y-2'>
<Label>展示名称</Label>
<Input
value={form.display_name}
onChange={(e) => setForm((prev) => ({ ...prev, display_name: e.target.value }))}
placeholder="例如: GitHub 登录"
onChange={(e) =>
setForm((prev) => ({ ...prev, display_name: e.target.value }))
}
placeholder='例如: GitHub 登录'
/>
</div>
<div className="space-y-2">
<div className='space-y-2'>
<Label>Client ID</Label>
<Input
value={form.client_id}
onChange={(e) => setForm((prev) => ({ ...prev, client_id: e.target.value }))}
onChange={(e) =>
setForm((prev) => ({ ...prev, client_id: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<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 ? "留空则保留原值" : ""}
onChange={(e) =>
setForm((prev) => ({ ...prev, client_secret: e.target.value }))
}
placeholder={source ? '留空则保留原值' : ''}
/>
</div>
<div className="space-y-2 md:col-span-2">
<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://..."
onChange={(e) =>
setForm((prev) => ({
...prev,
openid_discovery_url: e.target.value,
}))
}
placeholder='https://...'
/>
</div>
<div className="space-y-2">
<div className='space-y-2'>
<Label>Scopes</Label>
<Input
value={form.scopes}
onChange={(e) => setForm((prev) => ({ ...prev, scopes: e.target.value }))}
onChange={(e) =>
setForm((prev) => ({ ...prev, scopes: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<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://... 或留空"
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 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 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 }))} />
<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}>
<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
type='button'
onClick={saveSource}
disabled={saving}
variant='secondary'
>
{saving ? '保存中...' : '保存'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
);
}
+108 -64
View File
@@ -1,10 +1,10 @@
"use client"
'use client';
import {useMutation, useQuery} from "@tanstack/react-query"
import {ExternalLink, RefreshCw, Sparkles} from "lucide-react"
import {toast} from "sonner"
import { useMutation, useQuery } from '@tanstack/react-query';
import { ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import services from "@/lib/services"
import services from '@/lib/services';
import {
AlertDialog,
AlertDialogAction,
@@ -14,54 +14,64 @@ import {
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"
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 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"],
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("升级包已校验完成,服务正在重启")
toast.success('升级包已校验完成,服务正在重启');
},
onError: (error: Error) => {
toast.error(error.message || "应用升级失败")
toast.error(error.message || '应用升级失败');
},
})
});
const update = updateQuery.data
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 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">
<CardTitle className='text-base font-semibold'>
应用更新
</CardTitle>
<CardDescription className='text-xs'>
检查上游 GitHub Actions Release 并升级当前服务
</CardDescription>
</div>
@@ -69,75 +79,109 @@ export function InfoTab() {
{update?.update_available ? (
<Badge>发现新版本</Badge>
) : update ? (
<Badge variant="secondary">已是最新</Badge>
<Badge variant='secondary'>已是最新</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 pt-4">
<CardContent className='flex flex-col gap-4 pt-4'>
{updateQuery.isLoading ? (
<div className="flex min-h-32 items-center justify-center">
<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 || "无法获取上游版本信息"}
<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
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} />
<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">
<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" />}
<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" />
<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
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>
<AlertDialogTitle>
升级到 {update.latest_version}?
</AlertDialogTitle>
<AlertDialogDescription>
服务将下载并校验 {update.asset_name},随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
服务将下载并校验 {update.asset_name}
,随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => applyUpdateMutation.mutate()}>
<AlertDialogAction
onClick={() => applyUpdateMutation.mutate()}
>
确认升级
</AlertDialogAction>
</AlertDialogFooter>
@@ -146,12 +190,12 @@ export function InfoTab() {
</div>
{!update.can_upgrade && (
<p className="text-xs text-muted-foreground">
{update.current_version === "dev"
? "开发构建没有可比较的 Release 版本,不能执行自动升级。"
<p className='text-xs text-muted-foreground'>
{update.current_version === 'dev'
? '开发构建没有可比较的 Release 版本,不能执行自动升级。'
: update.update_available
? "当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。"
: "当前版本无需升级。"}
? '当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。'
: '当前版本无需升级。'}
</p>
)}
</>
@@ -159,5 +203,5 @@ export function InfoTab() {
</CardContent>
</Card>
</div>
)
);
}
@@ -1,60 +1,74 @@
"use client"
'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"
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()
const { showBell, setShowBell } = useNotificationSettings();
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<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>
<Link href='/settings' className='text-base text-primary'>
设置
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
<BreadcrumbPage className='text-base font-semibold'>
通知设置
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="space-y-6">
<div className='space-y-6'>
<div>
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
<p className="text-xs text-muted-foreground">
<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">
<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 className='text-xs text-muted-foreground'>
在顶部导航栏中显示通知铃铛图标
</p>
</div>
</div>
<Switch
id="show-bell"
id='show-bell'
checked={showBell}
onCheckedChange={setShowBell}
/>
</div>
</div>
</div>
)
);
}
@@ -1,158 +1,205 @@
"use client"
'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"
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>
configs: Record<string, SystemConfig>;
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>;
}
export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps) {
const queryClient = useQueryClient()
export function OperationTab({
configs,
systemConfigsQuery,
}: OperationTabProps) {
const queryClient = useQueryClient();
const uploadTypesQuery = useQuery({
queryKey: ["admin", "upload-types"],
queryKey: ['admin', 'upload-types'],
queryFn: () => services.adminSystemConfig.listUploadTypes(),
})
});
const updateWhitelistMutation = useMutation({
mutationFn: async (newValue: string) => {
const config = configs["file_access_whitelist"]
const config = configs['file_access_whitelist'];
if (!config) {
throw new Error("缺少配置项: file_access_whitelist")
throw new Error('缺少配置项: file_access_whitelist');
}
await services.adminSystemConfig.updateSystemConfig("file_access_whitelist", {
value: newValue,
description: config.description,
})
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("文件访问白名单已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('文件访问白名单已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新白名单失败")
toast.error(error.message || '更新白名单失败');
},
})
});
const whitelistConfig = configs["file_access_whitelist"]
const whitelistConfig = configs['file_access_whitelist'];
const currentWhitelist = useMemo<string[]>(() => {
if (!whitelistConfig?.value) return ["avatar"]
if (!whitelistConfig?.value) return ['avatar'];
try {
const parsed = JSON.parse(whitelistConfig.value)
if (Array.isArray(parsed)) return parsed
const parsed = JSON.parse(whitelistConfig.value);
if (Array.isArray(parsed)) return parsed;
} catch {
// 降级支持逗号分隔解析
return whitelistConfig.value.split(",").map(s => s.trim()).filter(Boolean)
return whitelistConfig.value
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
return ["avatar"]
}, [whitelistConfig?.value])
return ['avatar'];
}, [whitelistConfig?.value]);
const handleAddType = (type: string) => {
if (!type || currentWhitelist.includes(type)) return
const newWhitelist = [...currentWhitelist, type]
updateWhitelistMutation.mutate(JSON.stringify(newWhitelist))
}
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 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])
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">
<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-primary/10 text-primary">
<KeyRound className="size-4" />
<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-primary/10 text-primary'>
<KeyRound className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">文件访问权限控制</CardTitle>
<CardDescription className="text-xs">配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。</CardDescription>
<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>
<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=""
value=''
onValueChange={handleAddType}
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending || uploadTypesQuery.isPending}
disabled={
updateWhitelistMutation.isPending ||
systemConfigsQuery.isPending ||
uploadTypesQuery.isPending
}
>
<SelectTrigger className="w-[200px]" size="sm">
<SelectValue placeholder="选择业务类型..." />
<SelectTrigger className='w-[200px]' size='sm'>
<SelectValue placeholder='选择业务类型...' />
</SelectTrigger>
<SelectContent>
{availableTypes
.filter(t => !currentWhitelist.includes(t.value))
.map(t => (
.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>
{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-primary/30 transition-all duration-300 shadow-sm space-y-3">
<div className="flex items-center gap-2">
<ShieldAlert className="size-4 text-primary" />
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
<div className='rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/30 transition-all duration-300 shadow-sm space-y-3'>
<div className='flex items-center gap-2'>
<ShieldAlert className='size-4 text-primary' />
<span className='font-medium text-sm text-foreground'>
当前免鉴权列表
</span>
</div>
{currentWhitelist.length > 0 ? (
<div className="flex flex-wrap gap-2">
{currentWhitelist.map(type => (
<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-primary/10 text-primary dark:bg-primary/20 border border-primary/20"
variant='secondary'
className='px-2.5 py-1 text-xs gap-1.5 flex items-center bg-primary/10 text-primary dark:bg-primary/20 border border-primary/20'
>
{availableTypes.find(t => t.value === type)?.label || type}
{availableTypes.find((t) => t.value === type)?.label ||
type}
<button
type="button"
type='button'
onClick={() => handleRemoveType(type)}
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
className="rounded-full outline-hidden hover:bg-primary/20 p-0.5 text-primary cursor-pointer disabled:cursor-not-allowed"
disabled={
updateWhitelistMutation.isPending ||
systemConfigsQuery.isPending
}
className='rounded-full outline-hidden hover:bg-primary/20 p-0.5 text-primary cursor-pointer disabled:cursor-not-allowed'
>
<X className="size-3" />
<X className='size-3' />
</button>
</Badge>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">
<p className='text-xs text-muted-foreground'>
白名单已空,所有类型文件的访问都将需要登录。
</p>
)}
@@ -164,5 +211,5 @@ export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps)
{/* 通知模板管理 */}
<TemplatesManager />
</div>
)
);
}
+186 -91
View File
@@ -1,7 +1,7 @@
"use client"
'use client';
import {useMemo, ComponentType} from "react"
import {useMutation, useQueryClient} from "@tanstack/react-query"
import { ComponentType, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Bell,
Code,
@@ -16,170 +16,265 @@ import {
Settings,
ShieldCheck,
Terminal,
UserRound
} from "lucide-react"
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"
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
path: string;
label: string;
description: string;
icon: ComponentType<{ className?: string }>;
readOnly?: boolean;
}
interface MenuGroup {
name: string
items: MenuItem[]
name: string;
items: MenuItem[];
}
const MENU_GROUPS: MenuGroup[] = [
{
name: "基础菜单",
name: '基础菜单',
items: [
{ path: "/home", label: "首页", description: "系统控制台/个人首页", icon: Home },
{ path: "/files", label: "我的文件", description: "用户个人文件管理与上传", icon: FolderOpen },
]
{
path: '/home',
label: '首页',
description: '系统控制台/个人首页',
icon: Home,
},
{
path: '/files',
label: '我的文件',
description: '用户个人文件管理与上传',
icon: FolderOpen,
},
],
},
{
name: "管理菜单",
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 },
]
{
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: "文档菜单",
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 },
]
}
]
{
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>
configs: Record<string, SystemConfig>;
}
export function OtherTab({ configs }: OtherTabProps) {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
const menuDisplayConfig = useMemo(() => {
const raw = configs["menu_display_config"]?.value
if (!raw) return {} as Record<string, boolean>
const raw = configs['menu_display_config']?.value;
if (!raw) return {} as Record<string, boolean>;
try {
return JSON.parse(raw) as Record<string, boolean>
return JSON.parse(raw) as Record<string, boolean>;
} catch {
return {} as Record<string, boolean>
return {} as Record<string, boolean>;
}
}, [configs])
}, [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})",
})
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("目录显示配置已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('目录显示配置已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
toast.error(error.message || '更新配置失败');
},
})
});
const handleMenuToggle = (path: string, checked: boolean) => {
updateMenuConfigMutation.mutate({ path, enabled: checked })
}
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-primary/10 text-primary">
<LayoutList className="size-4" />
<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-primary/10 text-primary'>
<LayoutList className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">目录显示管理</CardTitle>
<CardDescription className="text-xs">
<CardTitle className='text-base font-semibold'>
目录显示管理
</CardTitle>
<CardDescription className='text-xs'>
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
<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">
<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 className='h-px bg-border/40 flex-1' />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<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
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-primary/30 transition-all duration-300 shadow-sm"
className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/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-primary shrink-0" />}
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
<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-primary 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 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">
<p className='text-xs text-muted-foreground leading-normal line-clamp-2'>
{item.description}
</p>
</div>
<div className="flex items-center">
<div className='flex items-center'>
<Switch
checked={checked}
disabled={isReadOnly || updateMenuConfigMutation.isPending}
onCheckedChange={(val) => handleMenuToggle(item.path, val)}
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-primary/20 bg-primary/5 flex items-start gap-2.5">
<Info className="size-4 text-primary shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground leading-relaxed">
<span className="font-semibold text-foreground">安全提示:</span>
<div className='p-3.5 rounded-lg border border-dashed border-primary/20 bg-primary/5 flex items-start gap-2.5'>
<Info className='size-4 text-primary 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>
)
);
}
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,12 @@
"use client"
'use client';
import {useEffect, useState} from "react"
import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query"
import { useEffect, useState } from 'react';
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryResult,
} from '@tanstack/react-query';
import {
Clock,
Fingerprint,
@@ -14,263 +19,309 @@ import {
Settings,
Shield,
Trash2,
UserPlus
} from "lucide-react"
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"
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 认证源进行系统登录。",
key: 'password_login_enabled',
title: '允许密码登录',
description: '关闭后仅保留第三方 OIDC 认证源进行系统登录。',
icon: Lock,
},
{
key: "registration_enabled",
title: "允许注册",
description: "关闭后系统将禁止新用户进行自主账号注册。",
key: 'registration_enabled',
title: '允许注册',
description: '关闭后系统将禁止新用户进行自主账号注册。',
icon: UserPlus,
},
{
key: "password_register_enabled",
title: "允许密码注册",
description: "关闭后只能通过管理员创建或第三方认证关联建号。",
key: 'password_register_enabled',
title: '允许密码注册',
description: '关闭后只能通过管理员创建或第三方认证关联建号。',
icon: Fingerprint,
},
{
key: "oidc_login_enabled",
title: "允许 OIDC 登录",
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
key: 'oidc_login_enabled',
title: '允许 OIDC 登录',
description: '关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。',
icon: Globe,
},
{
key: "email_login_verification_enabled",
title: "邮箱登录验证",
description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。",
key: 'email_login_verification_enabled',
title: '邮箱登录验证',
description:
'开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。',
icon: Mail,
},
{
key: "email_register_verification_enabled",
title: "邮箱注册验证",
description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。",
key: 'email_register_verification_enabled',
title: '邮箱注册验证',
description: '开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。',
icon: Mail,
},
] as const
] as const;
interface SecurityTabProps {
configs: Record<string, SystemConfig>
systemConfigsQuery: UseQueryResult<SystemConfig[], Error>
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 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 [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 [sessionTTL, setSessionTTL] = useState('168');
const [customHours, setCustomHours] = useState('');
const authSourcesQuery = useQuery({
queryKey: ["auth", "sources"],
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 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("")
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)
setSessionTTL('custom');
setCustomHours(ttlVal);
}
}
}, [systemConfigsQuery.data, configs])
}, [systemConfigsQuery.data, configs]);
const updateTTLMutation = useMutation({
mutationFn: async (value: string) => {
const config = configs["login_session_ttl_hours"]
const config = configs['login_session_ttl_hours'];
if (!config) {
throw new Error("缺少配置项: login_session_ttl_hours")
throw new Error('缺少配置项: login_session_ttl_hours');
}
await services.adminSystemConfig.updateSystemConfig("login_session_ttl_hours", {
value: value,
description: config.description,
})
await services.adminSystemConfig.updateSystemConfig(
'login_session_ttl_hours',
{
value: value,
description: config.description,
},
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("登录状态保持时间已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('登录状态保持时间已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
toast.error(error.message || '更新配置失败');
},
})
});
const handleTTLChange = (val: string) => {
setSessionTTL(val)
if (val !== "custom") {
updateTTLMutation.mutate(val)
setSessionTTL(val);
if (val !== 'custom') {
updateTTLMutation.mutate(val);
}
}
};
const handleCustomBlur = () => {
const parsed = parseInt(customHours, 10)
const parsed = parseInt(customHours, 10);
if (isNaN(parsed) || parsed <= 0) {
toast.error("请输入有效的过期小时数(正整数)")
toast.error('请输入有效的过期小时数(正整数)');
// 重置为原本的值
const originalVal = configs["login_session_ttl_hours"]?.value || "0"
setCustomHours(originalVal === "custom" || ["0", "168", "720", "-1"].includes(originalVal) ? "" : originalVal)
return
const originalVal = configs['login_session_ttl_hours']?.value || '0';
setCustomHours(
originalVal === 'custom' ||
['0', '168', '720', '-1'].includes(originalVal)
? ''
: originalVal,
);
return;
}
updateTTLMutation.mutate(parsed.toString())
}
updateTTLMutation.mutate(parsed.toString());
};
const updateConfigMutation = useMutation({
mutationFn: async ({ key, value }: { key: string; value: boolean }) => {
const config = configs[key]
const config = configs[key];
if (!config) {
throw new Error(`缺少配置项: ${key}`)
throw new Error(`缺少配置项: ${key}`);
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? "true" : "false",
value: value ? 'true' : 'false',
description: config.description,
})
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("系统安全配置已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('系统安全配置已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
toast.error(error.message || '更新配置失败');
},
})
});
const toggleSourceMutation = useMutation({
mutationFn: async (source: AuthSource) => {
await services.adminAuthSource.toggleAuthSource(source.id, { is_active: !source.is_active })
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("认证源状态已更新")
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
toast.success('认证源状态已更新');
},
onError: (error: Error) => {
toast.error(error.message || "切换状态失败")
toast.error(error.message || '切换状态失败');
},
})
});
const deleteSourceMutation = useMutation({
mutationFn: async (sourceId: string) => {
await services.adminAuthSource.deleteAuthSource(sourceId)
await services.adminAuthSource.deleteAuthSource(sourceId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
toast.success("认证源已删除")
await queryClient.invalidateQueries({ queryKey: ['auth', 'sources'] });
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
toast.success('认证源已删除');
},
onError: (error: Error) => {
toast.error(error.message || "删除认证源失败")
toast.error(error.message || '删除认证源失败');
},
})
});
const handleToggle = (key: string, checked: boolean) => {
updateConfigMutation.mutate({ key, value: checked })
}
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" },
]
{ 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]
const currentCfg = configs[update.key];
await services.adminSystemConfig.updateSystemConfig(update.key, {
value: update.value,
description: currentCfg?.description || "",
})
description: currentCfg?.description || '',
});
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("人机验证配置已成功保存")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('人机验证配置已成功保存');
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
toast.error(error.message || '保存配置失败');
},
})
});
const handleCapSave = (e: React.FormEvent) => {
e.preventDefault()
saveCapMutation.mutate()
}
e.preventDefault();
saveCapMutation.mutate();
};
return (
<div className="space-y-6">
<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-primary/10 text-primary">
<Settings className="size-4" />
<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-primary/10 text-primary'>
<Settings className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">系统登录/注册设置</CardTitle>
<CardDescription className="text-xs">配置系统的登录限制与用户自主注册权限</CardDescription>
<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">
<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
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-primary/30 transition-all duration-300 shadow-sm"
className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/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-primary" />}
<span className="font-medium text-sm text-foreground">{item.title}</span>
<div className='space-y-1'>
<div className='flex items-center gap-2'>
{Icon && <Icon className='size-4 text-primary' />}
<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>
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
{item.description}
</p>
</div>
<Switch
checked={checked}
@@ -278,56 +329,56 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
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-primary/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-primary" />
<span className="font-medium text-sm text-foreground">登录状态保持时间</span>
<div className='flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-primary/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-primary' />
<span className='font-medium text-sm text-foreground'>
登录状态保持时间
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<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 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>
<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" && (
{sessionTTL === 'custom' && (
<Input
type="number"
type='number'
min={1}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
onBlur={handleCustomBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleCustomBlur()
if (e.key === 'Enter') {
handleCustomBlur();
}
}}
placeholder="小时"
placeholder='小时'
disabled={updateTTLMutation.isPending}
className="w-20 bg-card border-dashed text-xs h-8 px-2"
className='w-20 bg-card border-dashed text-xs h-8 px-2'
/>
)}
</div>
@@ -337,116 +388,134 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</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-primary/10 text-primary">
<Globe className="size-4" />
<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-primary/10 text-primary'>
<Globe className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">认证源管理</CardTitle>
<CardDescription className="text-xs">添加、修改并启用系统自定义的 OIDC 认证源</CardDescription>
<CardTitle className='text-base font-semibold'>
认证源管理
</CardTitle>
<CardDescription className='text-xs'>
添加、修改并启用系统自定义的 OIDC 认证源
</CardDescription>
</div>
</div>
<Button
type="button"
size="sm"
type='button'
size='sm'
onClick={() => {
setSelectedSource(null)
setAuthSourceModalOpen(true)
setSelectedSource(null);
setAuthSourceModalOpen(true);
}}
variant="secondary"
variant='secondary'
>
<Plus className="mr-1.5 size-3.5" />
<Plus className='mr-1.5 size-3.5' />
新增认证源
</Button>
</CardHeader>
<CardContent className="pt-6 space-y-3">
<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 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"
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 ? "已启用" : "已禁用"}
<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">
<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-primary/5 text-primary border-primary/10"
: "bg-rose-500/5 text-rose-500 border-rose-500/10"
}`}>
{source.client_secret_configured ? "Secret 已配置" : "Secret 未配置"}
<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-primary/5 text-primary border-primary/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">
<div className='flex items-center gap-2'>
<Switch
checked={source.is_active}
disabled={toggleSourceMutation.isPending}
className="scale-90 mr-2"
onCheckedChange={() => toggleSourceMutation.mutate(source)}
className='scale-90 mr-2'
onCheckedChange={() =>
toggleSourceMutation.mutate(source)
}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
type='button'
variant='ghost'
size='icon'
className='size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors'
onClick={() => {
setSelectedSource(source)
setAuthSourceModalOpen(true)
setSelectedSource(source);
setAuthSourceModalOpen(true);
}}
>
<Pencil className="size-4" />
<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"
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)
if (
window.confirm(
`确定删除认证源「${source.display_name || source.name}」吗?`,
)
) {
deleteSourceMutation.mutate(source.id);
}
}}
>
<Trash2 className="size-4" />
<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">
<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"
type='button'
size='sm'
variant='outline'
onClick={() => {
setSelectedSource(null)
setAuthSourceModalOpen(true)
setSelectedSource(null);
setAuthSourceModalOpen(true);
}}
className="border-dashed"
className='border-dashed'
>
<Plus className="mr-1.5 size-3.5" />
<Plus className='mr-1.5 size-3.5' />
新增认证源
</Button>
</div>
@@ -455,123 +524,163 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</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-primary/10 text-primary">
<Shield className="size-4" />
<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-primary/10 text-primary'>
<Shield className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">人机验证配置 (Cap CAPTCHA)</CardTitle>
<CardDescription className="text-xs">配置基于 Proof-of-Work (PoW) 的无感人机验证,保护系统登录免受暴力破解和撞库攻击</CardDescription>
<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"}
checked={configs['cap_login_enabled']?.value === 'true'}
disabled={updateConfigMutation.isPending}
onCheckedChange={(checked) => handleToggle("cap_login_enabled", checked)}
onCheckedChange={(checked) =>
handleToggle('cap_login_enabled', checked)
}
/>
</CardHeader>
<CardContent className="pt-6">
<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 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}
/>
<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>
<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"
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"
placeholder='50'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">客户端需求解的难题总数。默认 1,推荐 1 至 5</p>
<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>
<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"
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"
placeholder='4'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4</p>
<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>
<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"
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"
placeholder='32'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">难题盐值混淆字符长度。默认 32</p>
<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>
<div className='space-y-1.5'>
<Label
htmlFor='cap_challenge_ttl'
className='text-xs font-semibold'
>
难题超时时长 (秒)
</Label>
<Input
id="cap_challenge_ttl"
type="number"
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"
placeholder='600'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">难题有效期限。默认 600 秒 (10 分钟)</p>
<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>
<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"
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"
placeholder='1200'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20 分钟)</p>
<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">
<div className='flex justify-end pt-4 border-t border-dashed'>
<Button
type="submit"
size="sm"
type='submit'
size='sm'
disabled={saveCapMutation.isPending}
>
{saveCapMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
</>
) : (
"保存配置"
'保存配置'
)}
</Button>
</div>
@@ -584,11 +693,15 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
source={selectedSource}
onClose={() => setAuthSourceModalOpen(false)}
onChanged={async () => {
await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] })
await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] })
await authSourcesQuery.refetch()
await queryClient.invalidateQueries({
queryKey: ['auth', 'sources'],
});
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
await authSourcesQuery.refetch();
}}
/>
</div>
)
);
}
+296 -227
View File
@@ -1,152 +1,178 @@
"use client"
'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 { 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"
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>
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("")
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 || "")
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])
}, [systemConfigsQuery.data, configs]);
const handleDetectAddress = () => {
if (typeof window !== "undefined") {
setServerAddress(window.location.origin)
toast.success("已自动获取当前域名并填充")
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]
const config = configs[key];
if (!config) {
throw new Error(`缺少配置项: ${key}`)
throw new Error(`缺少配置项: ${key}`);
}
await services.adminSystemConfig.updateSystemConfig(key, {
value: value ? "true" : "false",
value: value ? 'true' : 'false',
description: config.description,
})
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("配置已更新")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('配置已更新');
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
toast.error(error.message || '更新配置失败');
},
})
});
const saveSystemMutation = useMutation({
mutationFn: async () => {
const currentAddrCfg = configs["server_address"]
const currentSiteCfg = configs["site_name"]
const currentAddrCfg = configs['server_address'];
const currentSiteCfg = configs['site_name'];
await Promise.all([
services.adminSystemConfig.updateSystemConfig("server_address", {
services.adminSystemConfig.updateSystemConfig('server_address', {
value: serverAddress,
description: currentAddrCfg?.description || "服务器地址",
description: currentAddrCfg?.description || '服务器地址',
}),
services.adminSystemConfig.updateSystemConfig("site_name", {
services.adminSystemConfig.updateSystemConfig('site_name', {
value: siteName,
description: currentSiteCfg?.description || "系统平台的展示名称",
description: currentSiteCfg?.description || '系统平台的展示名称',
}),
])
]);
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }),
queryClient.invalidateQueries({ queryKey: ["public-config"] }),
])
toast.success("通用配置已成功保存")
queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
}),
queryClient.invalidateQueries({ queryKey: ['public-config'] }),
]);
toast.success('通用配置已成功保存');
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
toast.error(error.message || '保存配置失败');
},
})
});
const handleSystemSave = (e: React.FormEvent) => {
e.preventDefault()
saveSystemMutation.mutate()
}
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 },
]
{ 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 === "******")) {
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
if (currentCfg && currentCfg.value === '******') {
continue;
}
}
await services.adminSystemConfig.updateSystemConfig(update.key, {
value: update.value,
description: currentCfg?.description || "",
})
description: currentCfg?.description || '',
});
}
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
toast.success("SMTP 邮件配置已成功保存")
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('SMTP 邮件配置已成功保存');
},
onError: (error: Error) => {
toast.error(error.message || "保存配置失败")
toast.error(error.message || '保存配置失败');
},
})
});
const handleSmtpSave = (e: React.FormEvent) => {
e.preventDefault()
saveSmtpMutation.mutate()
}
e.preventDefault();
saveSmtpMutation.mutate();
};
const testSmtpMutation = useMutation({
mutationFn: async () => {
setSmtpTestLog("正在发起连接测试...\n")
setSmtpTestSuccess(null)
setSmtpTestError("")
setSmtpTestLog('正在发起连接测试...\n');
setSmtpTestSuccess(null);
setSmtpTestError('');
const res = await services.adminSystemConfig.testSMTP({
smtp_host: smtpHost,
@@ -154,151 +180,167 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
smtp_username: smtpUsername,
smtp_password: smtpPassword,
to: smtpTestTo,
})
return res
});
return res;
},
onSuccess: (data) => {
setSmtpTestLog(data.log)
setSmtpTestLog(data.log);
if (data.success) {
setSmtpTestSuccess(true)
toast.success("测试邮件发送成功")
setSmtpTestSuccess(true);
toast.success('测试邮件发送成功');
} else {
setSmtpTestSuccess(false)
setSmtpTestError(data.error || "发送失败,请检查配置和日志。")
toast.error("测试邮件发送失败")
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 || "测试请求发送失败")
setSmtpTestSuccess(false);
setSmtpTestError(error.message || '请求发送失败');
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`);
toast.error(error.message || '测试请求发送失败');
},
})
});
const handleSmtpTestSubmit = (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!smtpTestTo) {
toast.error("请输入目标邮箱地址")
return
toast.error('请输入目标邮箱地址');
return;
}
testSmtpMutation.mutate()
}
testSmtpMutation.mutate();
};
const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true"
const indexingEnabled =
configs['search_engine_indexing_enabled']?.value === 'true';
return (
<div className="space-y-8">
<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-primary/10 p-1.5 text-primary">
<Server className="size-4" />
<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-primary/10 p-1.5 text-primary'>
<Server className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">通用设置</CardTitle>
<CardDescription className="text-xs">
<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">
<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"
id='site_name'
type='text'
value={siteName}
onChange={(e) => setSiteName(e.target.value)}
placeholder="例如: Wavelet"
className="border-dashed bg-card text-xs"
placeholder='例如: Wavelet'
className='border-dashed bg-card text-xs'
/>
<p className="text-[11px] leading-relaxed text-muted-foreground">
<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">
<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"
type='button'
variant='link'
size='sm'
onClick={handleDetectAddress}
className="h-auto px-0 text-xs"
className='h-auto px-0 text-xs'
>
<Sparkles data-icon="inline-start" />
<Sparkles data-icon='inline-start' />
使用当前域名
</Button>
</div>
<Input
id="server_address"
type="url"
id='server_address'
type='url'
value={serverAddress}
onChange={(e) => setServerAddress(e.target.value)}
placeholder="例如: https://example.com"
className="border-dashed bg-card text-xs"
placeholder='例如: https://example.com'
className='border-dashed bg-card text-xs'
/>
<p className="text-[11px] leading-relaxed text-muted-foreground">
<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 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 ? "允许收录" : "禁止收录"}
<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">
<p className='text-[11px] leading-relaxed text-muted-foreground'>
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
</p>
</div>
</div>
<Switch
aria-label="允许搜索引擎抓取与收录"
aria-label='允许搜索引擎抓取与收录'
checked={indexingEnabled}
disabled={updateConfigMutation.isPending || systemConfigsQuery.isPending}
disabled={
updateConfigMutation.isPending || systemConfigsQuery.isPending
}
onCheckedChange={(checked) =>
updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: 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 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"
type='submit'
size='sm'
disabled={saveSystemMutation.isPending}
>
{saveSystemMutation.isPending ? (
<>
<Loader2 data-icon="inline-start" className="animate-spin" />
<Loader2
data-icon='inline-start'
className='animate-spin'
/>
保存中...
</>
) : (
"保存配置"
'保存配置'
)}
</Button>
</div>
@@ -307,98 +349,120 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</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-primary/10 text-primary">
<Mail className="size-4" />
<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-primary/10 text-primary'>
<Mail className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">SMTP 邮件设置</CardTitle>
<CardDescription className="text-xs">配置系统的邮件发送服务 (SMTP)</CardDescription>
<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>
<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"
id='smtp_host'
type='text'
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
placeholder="例如: smtp.example.com"
className="bg-card border-dashed text-xs"
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>
<div className='space-y-1.5'>
<Label htmlFor='smtp_port' className='text-xs font-semibold'>
SMTP 端口
</Label>
<Input
id="smtp_port"
type="number"
id='smtp_port'
type='number'
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
placeholder="例如: 587 或 465"
className="bg-card border-dashed text-xs"
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>
<div className='space-y-1.5'>
<Label
htmlFor='smtp_username'
className='text-xs font-semibold'
>
SMTP 账户
</Label>
<Input
id="smtp_username"
type="text"
id='smtp_username'
type='text'
value={smtpUsername}
onChange={(e) => setSmtpUsername(e.target.value)}
placeholder="例如: sender@example.com"
className="bg-card border-dashed text-xs"
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>
<div className='space-y-1.5'>
<Label
htmlFor='smtp_password'
className='text-xs font-semibold'
>
SMTP 访问凭证
</Label>
<Input
id="smtp_password"
type="password"
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"
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">
<div className='flex justify-end gap-2 pt-4 border-t border-dashed'>
<Button
type="button"
variant="outline"
size="sm"
type='button'
variant='outline'
size='sm'
onClick={() => {
setSmtpTestOpen(true)
setSmtpTestTo("")
setSmtpTestLog("")
setSmtpTestSuccess(null)
setSmtpTestError("")
setSmtpTestOpen(true);
setSmtpTestTo('');
setSmtpTestLog('');
setSmtpTestSuccess(null);
setSmtpTestError('');
}}
disabled={saveSmtpMutation.isPending}
>
测试发件
</Button>
<Button
type="submit"
size="sm"
type='submit'
size='sm'
disabled={saveSmtpMutation.isPending}
>
{saveSmtpMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
</>
) : (
"保存配置"
'保存配置'
)}
</Button>
</div>
@@ -407,72 +471,77 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</Card>
<Dialog open={smtpTestOpen} onOpenChange={setSmtpTestOpen}>
<DialogContent className="max-w-lg border border-dashed">
<DialogContent className='max-w-lg border border-dashed'>
<DialogHeader>
<DialogTitle className="text-base font-semibold">SMTP 发件测试</DialogTitle>
<DialogDescription className="text-xs">
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP 配置进行发件测试。
<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>
<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"
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"
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">
<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 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">
<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">
<DialogFooter className='gap-2 sm:gap-0 border-t border-dashed pt-4'>
<Button
type="button"
variant="ghost"
size="sm"
type='button'
variant='ghost'
size='sm'
onClick={() => setSmtpTestOpen(false)}
disabled={testSmtpMutation.isPending}
>
关闭
</Button>
<Button
type="submit"
size="sm"
type='submit'
size='sm'
disabled={testSmtpMutation.isPending}
>
{testSmtpMutation.isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
测试中...
</>
) : (
"开始测试"
'开始测试'
)}
</Button>
</DialogFooter>
@@ -480,5 +549,5 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</DialogContent>
</Dialog>
</div>
)
);
}
+201 -172
View File
@@ -1,36 +1,51 @@
"use client"
'use client';
import {useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {FileText, Loader2, Pencil, Plus, Trash2} from "lucide-react"
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"
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)
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 [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"],
queryKey: ['admin', 'templates'],
queryFn: () => services.adminTemplate.listTemplates(),
})
});
const createTemplateMutation = useMutation({
mutationFn: async () => {
@@ -41,17 +56,17 @@ export function TemplatesManager() {
subject,
content,
description,
})
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已成功创建")
setModalOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已成功创建');
setModalOpen(false);
},
onError: (error: Error) => {
toast.error(error.message || "创建模板失败")
toast.error(error.message || '创建模板失败');
},
})
});
const updateTemplateMutation = useMutation({
mutationFn: async (key: string) => {
@@ -61,164 +76,178 @@ export function TemplatesManager() {
subject,
content,
description,
})
});
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已成功保存")
setModalOpen(false)
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已成功保存');
setModalOpen(false);
},
onError: (error: Error) => {
toast.error(error.message || "修改模板失败")
toast.error(error.message || '修改模板失败');
},
})
});
const deleteTemplateMutation = useMutation({
mutationFn: async (key: string) => {
await services.adminTemplate.deleteTemplate(key)
await services.adminTemplate.deleteTemplate(key);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "templates"] })
toast.success("通知模板已删除")
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已删除');
},
onError: (error: Error) => {
toast.error(error.message || "删除模板失败")
toast.error(error.message || '删除模板失败');
},
})
});
const handleOpenCreate = () => {
setSelectedTemplate(null)
setKey("")
setName("")
setType("email")
setSubject("")
setContent("")
setDescription("")
setModalOpen(true)
}
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)
}
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()
e.preventDefault();
if (selectedTemplate) {
updateTemplateMutation.mutate(selectedTemplate.key)
updateTemplateMutation.mutate(selectedTemplate.key);
} else {
createTemplateMutation.mutate()
createTemplateMutation.mutate();
}
}
};
const isPending = createTemplateMutation.isPending || updateTemplateMutation.isPending
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-primary/10 text-primary">
<FileText className="size-4" />
<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-primary/10 text-primary'>
<FileText className='size-4' />
</div>
<div>
<CardTitle className="text-base font-semibold">通知模板管理</CardTitle>
<CardDescription className="text-xs">
<CardTitle className='text-base font-semibold'>
通知模板管理
</CardTitle>
<CardDescription className='text-xs'>
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
</CardDescription>
</div>
</div>
<Button
type="button"
size="sm"
type='button'
size='sm'
onClick={handleOpenCreate}
variant="secondary"
variant='secondary'
>
<Plus className="mr-1.5 size-3.5" />
<Plus className='mr-1.5 size-3.5' />
新增模板
</Button>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<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 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">
<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-primary/30 transition-all duration-300 shadow-sm"
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-primary/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-primary/10 text-primary border-primary/20"
: "bg-amber-500/10 text-amber-500 border-amber-500/20"
}`}>
{tmpl.is_system ? "系统内置" : "自定义"}
<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 border-border/50 bg-muted/50 text-muted-foreground font-mono">
<span
className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${
tmpl.is_system
? 'bg-primary/10 text-primary border-primary/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-primary bg-primary/5 px-1.5 py-0.5 rounded">{tmpl.key}</span>
<div className='text-xs text-muted-foreground'>
标识符:{' '}
<span className='font-mono text-primary bg-primary/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">
<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">
<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-primary hover:bg-primary/10 rounded-lg transition-colors"
type='button'
variant='ghost'
size='icon'
className='size-8 text-muted-foreground hover:text-primary hover:bg-primary/10 rounded-lg transition-colors'
onClick={() => handleOpenEdit(tmpl)}
>
<Pencil className="size-4" />
<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}
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)
if (
window.confirm(`确定删除模板「${tmpl.name}」吗?`)
) {
deleteTemplateMutation.mutate(tmpl.key);
}
}}
>
<Trash2 className="size-4" />
<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">
<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"
type='button'
size='sm'
variant='outline'
onClick={handleOpenCreate}
className="border-dashed"
className='border-dashed'
>
<Plus className="mr-1.5 size-3.5" />
<Plus className='mr-1.5 size-3.5' />
新增模板
</Button>
</div>
@@ -227,142 +256,142 @@ export function TemplatesManager() {
</Card>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-xl border border-dashed">
<DialogContent className='max-w-xl border border-dashed'>
<DialogHeader>
<DialogTitle className="text-base font-semibold">
{selectedTemplate ? "编辑通知模板" : "新增通知模板"}
<DialogTitle className='text-base font-semibold'>
{selectedTemplate ? '编辑通知模板' : '新增通知模板'}
</DialogTitle>
<DialogDescription className="text-xs">
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `{"{{.Code}}"}` 表示验证码变量)。
<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">
<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"
id='tmpl_key'
type='text'
required
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="例如: login_email"
className="bg-card border-dashed text-xs"
placeholder='例如: login_email'
className='bg-card border-dashed text-xs'
disabled={!!selectedTemplate}
/>
<p className="text-[10px] text-muted-foreground leading-normal">
<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">
<div className='space-y-1.5'>
<Label htmlFor='tmpl_name' className='text-xs font-semibold'>
模板名称
</Label>
<Input
id="tmpl_name"
type="text"
id='tmpl_name'
type='text'
required
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如: 登录验证码邮件"
className="bg-card border-dashed text-xs"
placeholder='例如: 登录验证码邮件'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">
<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">
<div className='space-y-1.5'>
<Label htmlFor='tmpl_type' className='text-xs font-semibold'>
模板类型
</Label>
<Input
id="tmpl_type"
type="text"
id='tmpl_type'
type='text'
required
value={type}
onChange={(e) => setType(e.target.value)}
placeholder="email"
className="bg-card border-dashed text-xs"
placeholder='email'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">
<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">
<div className='space-y-1.5'>
<Label htmlFor='tmpl_subject' className='text-xs font-semibold'>
模板主题 (Subject)
</Label>
<Input
id="tmpl_subject"
type="text"
id='tmpl_subject'
type='text'
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="例如: Wavelet 登录验证码"
className="bg-card border-dashed text-xs"
placeholder='例如: Wavelet 登录验证码'
className='bg-card border-dashed text-xs'
/>
<p className="text-[10px] text-muted-foreground leading-normal">
<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">
<div className='space-y-1.5'>
<Label
htmlFor='tmpl_description'
className='text-xs font-semibold'
>
模板说明与变量描述
</Label>
<Input
id="tmpl_description"
type="text"
id='tmpl_description'
type='text'
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="例如: 包含变量:{{.Code}},5分钟内有效"
className="bg-card border-dashed text-xs"
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">
<div className='space-y-1.5'>
<Label htmlFor='tmpl_content' className='text-xs font-semibold'>
模板正文内容 (Content)
</Label>
<Textarea
id="tmpl_content"
id='tmpl_content'
required
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>"
placeholder='<h3>正文标题</h3><p>内容段落,可用变量 {{.Code}}</p>'
rows={8}
className="bg-card border-dashed text-xs font-mono"
className='bg-card border-dashed text-xs font-mono'
/>
</div>
<DialogFooter className="gap-2 sm:gap-0 border-t border-dashed pt-4">
<DialogFooter className='gap-2 sm:gap-0 border-t border-dashed pt-4'>
<Button
type="button"
variant="ghost"
size="sm"
type='button'
variant='ghost'
size='sm'
onClick={() => setModalOpen(false)}
disabled={isPending}
>
取消
</Button>
<Button
type="submit"
size="sm"
disabled={isPending}
>
<Button type='submit' size='sm' disabled={isPending}>
{isPending ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
</>
) : (
"保存配置"
'保存配置'
)}
</Button>
</DialogFooter>
@@ -370,5 +399,5 @@ export function TemplatesManager() {
</DialogContent>
</Dialog>
</div>
)
);
}