个人信息页面增强

This commit is contained in:
ryan
2026-06-08 19:38:53 +08:00
parent b96624a251
commit 0f65202659
22 changed files with 1568 additions and 370 deletions
+245 -82
View File
@@ -5,6 +5,7 @@ import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {AnimatePresence, motion} from "motion/react"
import {
Download,
Eye,
FileArchive,
FileAudio,
FileImage,
@@ -21,6 +22,8 @@ import {toast} from "sonner"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Badge} from "@/components/ui/badge"
import {Checkbox} from "@/components/ui/checkbox"
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
import {
AlertDialog,
AlertDialogAction,
@@ -31,6 +34,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from "@/components/ui/sheet"
import {formatFileSize, UploadService} from "@/lib/services/upload/upload.service"
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
@@ -63,8 +67,9 @@ export function FilesMain() {
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
const [page, setPage] = React.useState(1)
const pageSize = 24
const pageSize = 15 // 表格视图下 15 条更紧凑
// 搜索防抖
React.useEffect(() => {
@@ -85,6 +90,25 @@ export function FilesMain() {
const total = listQuery.data?.total ?? 0
const totalPages = Math.ceil(total / pageSize)
const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id))
const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected
const handleSelectAll = () => {
if (isAllSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
files.forEach((f) => next.delete(f.id))
return next
})
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
files.forEach((f) => next.add(f.id))
return next
})
}
}
// 删除单文件
const deleteMutation = useMutation({
mutationFn: (id: string) => UploadService.deleteFile(id),
@@ -147,7 +171,7 @@ export function FilesMain() {
文件管理
</h1>
<p className="text-sm text-muted-foreground">
管理您上传的所有文件,支持下载 and 批量操作
管理您上传的所有文件,支持下载与批量操作
</p>
</div>
</div>
@@ -227,7 +251,7 @@ export function FilesMain() {
)}
</div>
{/* 文件网格 */}
{/* 文件列表 Table */}
{listQuery.isPending ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="size-6 animate-spin text-sky-500" />
@@ -240,86 +264,119 @@ export function FilesMain() {
</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{files.map((file, idx) => {
const isSelected = selectedIds.has(file.id)
return (
<motion.div
key={file.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: idx * 0.02 }}
className={`group relative rounded-xl border border-dashed p-3 flex flex-col gap-2 cursor-pointer transition-all duration-200 hover:bg-muted/40 hover:shadow-sm select-none ${
isSelected
? "bg-sky-500/8 border-sky-500/40 ring-1 ring-sky-500/30"
: "bg-card hover:border-muted-foreground/25"
}`}
onClick={() => toggleSelect(file.id)}
>
{/* 选中指示 */}
{isSelected && (
<div className="absolute top-2 left-2 size-4 rounded-full bg-sky-500 flex items-center justify-center z-10">
<svg className="size-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
)}
{/* 文件图标 / 图片预览 */}
<div className="flex items-center justify-center h-14 rounded-lg bg-muted/30 overflow-hidden">
{file.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/f/${file.id}`}
alt={file.file_name}
className="h-full w-full object-cover rounded-lg"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = "none"
}}
/>
) : (
getFileIcon(file.mime_type)
)}
</div>
{/* 文件名 */}
<p className="text-[10px] font-medium leading-tight text-foreground/80 break-all line-clamp-2 text-center">
{file.file_name}
</p>
{/* 元数据 */}
<div className="text-[10px] text-muted-foreground text-center space-y-0.5">
<p>{formatFileSize(file.file_size)}</p>
<p className="truncate">{formatDate(file.created_at)}</p>
</div>
{/* Hover 操作按钮 */}
<div
className="absolute inset-x-2 bottom-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<Button
size="icon"
variant="secondary"
className="h-6 w-6 rounded-md flex-1"
title="下载"
onClick={() => handleDownload(file)}
<div className="border border-dashed rounded-xl bg-card overflow-hidden">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent border-dashed">
<TableHead className="w-[50px] pl-4 py-3">
<Checkbox
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className="w-[80px] py-3">预览</TableHead>
<TableHead className="w-[180px] py-3">ID</TableHead>
<TableHead className="py-3">文件名</TableHead>
<TableHead className="max-w-[200px] truncate py-3">路径</TableHead>
<TableHead className="w-[100px] py-3">业务类别</TableHead>
<TableHead className="w-[125px] py-3">MIME类型</TableHead>
<TableHead className="w-[100px] py-3">大小</TableHead>
<TableHead className="w-[150px] py-3">上传时间</TableHead>
<TableHead className="w-[120px] text-right pr-4 py-3">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => {
const isSelected = selectedIds.has(file.id)
return (
<TableRow
key={file.id}
className={`border-dashed hover:bg-muted/30 transition-colors ${
isSelected ? "bg-sky-500/5 hover:bg-sky-500/10" : ""
}`}
>
<Download className="size-3" />
</Button>
<Button
size="icon"
variant="secondary"
className="h-6 w-6 rounded-md text-destructive hover:bg-destructive/10"
title="删除"
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3" />
</Button>
</div>
</motion.div>
)
})}
<TableCell className="pl-4 py-3">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(file.id)}
/>
</TableCell>
<TableCell className="py-3">
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
{file.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/f/${file.id}`}
alt={file.file_name}
className="size-full object-cover"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = "none"
}}
/>
) : (
getFileIcon(file.mime_type, "size-4.5")
)}
</div>
</TableCell>
<TableCell className="py-3 font-mono text-[11px] text-muted-foreground select-all">
{file.id}
</TableCell>
<TableCell className="py-3 font-medium max-w-[180px] truncate text-xs" title={file.file_name}>
{file.file_name}
</TableCell>
<TableCell className="py-3 font-mono text-[11px] max-w-[200px] truncate text-muted-foreground select-all" title={file.file_path}>
{file.file_path}
</TableCell>
<TableCell className="py-3">
<Badge variant="secondary" className="text-[10px] py-0 px-1.5 font-normal rounded-md">
{file.type}
</Badge>
</TableCell>
<TableCell className="py-3 text-[11px] text-muted-foreground truncate max-w-[125px]" title={file.mime_type}>
{file.mime_type}
</TableCell>
<TableCell className="py-3 text-xs">
{formatFileSize(file.file_size)}
</TableCell>
<TableCell className="py-3 text-xs text-muted-foreground">
{formatDate(file.created_at)}
</TableCell>
<TableCell className="py-3 text-right pr-4">
<div className="flex items-center justify-end gap-0.5">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground"
title="查看详情"
onClick={() => setDetailTarget(file)}
>
<Eye className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-muted"
title="下载"
onClick={() => handleDownload(file)}
>
<Download className="size-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="删除"
onClick={() => setDeleteTarget(file)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)}
@@ -350,6 +407,112 @@ export function FilesMain() {
</div>
)}
{/* 详情查看 Sheet */}
<Sheet open={!!detailTarget} onOpenChange={(open) => !open && setDetailTarget(null)}>
<SheetContent className="w-full sm:max-w-md overflow-y-auto p-6">
<SheetHeader className="border-b pb-4 px-0">
<SheetTitle>文件详情</SheetTitle>
<SheetDescription>查看文件的完整元数据与配置属性。</SheetDescription>
</SheetHeader>
{detailTarget && (
<div className="space-y-6 py-4">
{/* 大图/格式预览 */}
<div className="flex items-center justify-center h-48 rounded-xl bg-muted/30 border border-dashed overflow-hidden p-2">
{detailTarget.mime_type.startsWith("image/") ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`/f/${detailTarget.id}`}
alt={detailTarget.file_name}
className="max-h-full max-w-full object-contain rounded-lg shadow-sm"
/>
) : (
<div className="flex flex-col items-center gap-3">
{getFileIcon(detailTarget.mime_type, "size-14")}
<span className="text-xs font-medium text-muted-foreground uppercase">
{detailTarget.extension} 文件
</span>
</div>
)}
</div>
{/* 元数据列表 */}
<div className="space-y-4 text-xs">
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件 ID</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件名</span>
<span className="col-span-2 break-all font-medium text-foreground/90">{detailTarget.file_name}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储路径</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.file_path}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">大小</span>
<span className="col-span-2 text-foreground/90">
{formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节)
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">MIME 类型</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.mime_type}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">文件后缀</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.extension}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">Hash (SHA-256)</span>
<span className="col-span-2 font-mono break-all select-all text-foreground/90">{detailTarget.hash}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">业务类型</span>
<span className="col-span-2">
<Badge variant="secondary" className="text-[10px] py-0 px-2 rounded-md font-normal">
{detailTarget.type}
</Badge>
</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">存储驱动</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.storage_driver || "local"}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传者 ID</span>
<span className="col-span-2 font-mono text-foreground/90">{detailTarget.user_id}</span>
</div>
<div className="grid grid-cols-3 gap-2 border-b border-dashed pb-2">
<span className="text-muted-foreground font-medium">上传时间</span>
<span className="col-span-2 text-foreground/90">{formatDate(detailTarget.created_at)}</span>
</div>
{detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && (
<div className="space-y-1.5 pt-2">
<span className="text-muted-foreground font-medium block">额外元数据</span>
<pre className="p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto">
{JSON.stringify(detailTarget.metadata, null, 2)}
</pre>
</div>
)}
</div>
</div>
)}
</SheetContent>
</Sheet>
{/* 删除确认 Dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
+553 -159
View File
@@ -14,11 +14,40 @@ import {
BreadcrumbSeparator
} from "@/components/ui/breadcrumb"
import {useUser} from "@/contexts/user-context"
import {ArrowRight, Info, Link2, Loader2, Lock, Shield, Unlink} from "lucide-react"
import {
ArrowRight,
BookOpen,
Camera,
Edit,
Globe,
Info,
Link2,
Loader2,
Lock,
Mail,
MapPin,
Phone,
Shield,
Unlink,
User as UserIcon
} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Separator} from "@/components/ui/separator"
import {Textarea} from "@/components/ui/textarea"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import {Label} from "@/components/ui/label"
import {AuthService} from "@/lib/services"
import {UploadService} from "@/lib/services/upload/upload.service"
import {ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset} from "@/components/ui/image-crop"
import {toast} from "sonner"
export function ProfileMain() {
@@ -27,10 +56,41 @@ export function ProfileMain() {
const isAnimatingRef = React.useRef(false)
const queryClient = useQueryClient()
// 修改密码 State
const [oldPassword, setOldPassword] = React.useState("")
const [newPassword, setNewPassword] = React.useState("")
const [confirmPassword, setConfirmPassword] = React.useState("")
// 编辑个人资料 State
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [nickname, setNickname] = React.useState("")
const [email, setEmail] = React.useState("")
const [bio, setBio] = React.useState("")
const [phone, setPhone] = React.useState("")
const [gender, setGender] = React.useState("secret")
const [website, setWebsite] = React.useState("")
const [location, setLocation] = React.useState("")
const [avatarUrl, setAvatarUrl] = React.useState("")
// 头像裁剪 State
const [cropFile, setCropFile] = React.useState<File | null>(null)
const [isCropDialogOpen, setIsCropDialogOpen] = React.useState(false)
const fileInputRef = React.useRef<HTMLInputElement>(null)
// 初始化编辑表单数据
React.useEffect(() => {
if (user) {
setNickname(user.nickname || "")
setEmail(user.email || "")
setBio(user.bio || "")
setPhone(user.phone || "")
setGender(user.gender || "secret")
setWebsite(user.website || "")
setLocation(user.location || "")
setAvatarUrl(user.avatar_url || "")
}
}, [user, isEditDialogOpen])
const changePasswordMutation = useMutation({
mutationFn: (req: any) => AuthService.changePassword(req),
onSuccess: () => {
@@ -61,6 +121,32 @@ export function ProfileMain() {
})
}
const updateProfileMutation = useMutation({
mutationFn: (req: any) => AuthService.updateProfile(req),
onSuccess: () => {
toast.success("个人信息修改成功")
setIsEditDialogOpen(false)
void refetch()
},
onError: (error: Error) => {
toast.error(error.message || "修改个人信息失败,请重试")
},
})
const handleProfileSubmit = (e: React.FormEvent) => {
e.preventDefault()
updateProfileMutation.mutate({
nickname,
email,
avatar_url: avatarUrl,
bio,
phone,
gender,
website,
location,
})
}
const externalAccountBindingsQuery = useQuery({
queryKey: ["auth", "external-accounts"],
queryFn: () => AuthService.getExternalAccountBindings(),
@@ -96,9 +182,38 @@ export function ProfileMain() {
}, 650)
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setCropFile(file)
setIsCropDialogOpen(true)
}
}
const handleCroppedImage = async (croppedBase64: string) => {
try {
const res = await UploadService.uploadBase64Image(croppedBase64, "avatar", "avatar.png")
setAvatarUrl(`/f/${res.id}`)
setIsCropDialogOpen(false)
setCropFile(null)
toast.success("头像上传成功,点击保存以生效")
} catch (err: any) {
toast.error(err.message || "上传头像失败")
}
}
const getGenderLabel = (g?: string) => {
switch (g) {
case "male": return "男"
case "female": return "女"
case "secret": return "保密"
default: return g || "保密"
}
}
if (loading) {
return (
<div className="py-6 space-y-4">
<div className="py-6 space-y-4 max-w-4xl mx-auto">
<div className="border-b border-border pb-4">
<h1 className="text-2xl font-semibold">个人资料</h1>
</div>
@@ -108,7 +223,7 @@ export function ProfileMain() {
if (!user) {
return (
<div className="py-6 space-y-6">
<div className="py-6 space-y-6 max-w-4xl mx-auto">
<div className="text-sm text-muted-foreground">未找到用户信息</div>
</div>
)
@@ -132,10 +247,22 @@ export function ProfileMain() {
</Breadcrumb>
</div>
{/* 基本资料面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4">
<h2 className="text-lg font-semibold tracking-tight">基本资料</h2>
<p className="text-xs text-muted-foreground">您的个人账户基本信息</p>
<div className="border-b pb-4 flex justify-between items-center">
<div>
<h2 className="text-lg font-semibold tracking-tight">基本资料</h2>
<p className="text-xs text-muted-foreground">您的个人账户基本信息</p>
</div>
<Button
variant="outline"
size="sm"
className="text-xs border-dashed"
onClick={() => setIsEditDialogOpen(true)}
>
<Edit className="size-3.5 mr-1.5" />
编辑资料
</Button>
</div>
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 pt-2">
@@ -156,24 +283,87 @@ export function ProfileMain() {
<div className="flex-1 w-full space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">账户</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<UserIcon className="size-3 text-muted-foreground/70" />
账户
</div>
<div className="text-sm font-semibold">@{user.username}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">昵称</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<UserIcon className="size-3 text-muted-foreground/70" />
昵称
</div>
<div className="text-sm font-semibold">{user.nickname || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">用户ID (UID)</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Info className="size-3 text-muted-foreground/70" />
用户ID (UID)
</div>
<div className="text-sm font-mono font-semibold">{user.id}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Mail className="size-3 text-muted-foreground/70" />
邮箱
</div>
<div className="text-sm font-semibold truncate max-w-[220px]">{user.email || '未绑定'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">管理员身份</div>
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Phone className="size-3 text-muted-foreground/70" />
手机号码
</div>
<div className="text-sm font-semibold">{user.phone || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<UserIcon className="size-3 text-muted-foreground/70" />
性别
</div>
<div className="text-sm font-semibold">{getGenderLabel(user.gender)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Globe className="size-3 text-muted-foreground/70" />
个人网站
</div>
<div className="text-sm font-semibold truncate max-w-[220px]">
{user.website ? (
<a
href={user.website.startsWith("http") ? user.website : `http://${user.website}`}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:underline"
>
{user.website}
</a>
) : (
'未设置'
)}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<MapPin className="size-3 text-muted-foreground/70" />
所在地
</div>
<div className="text-sm font-semibold">{user.location || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<Shield className="size-3 text-muted-foreground/70" />
管理员身份
</div>
<div className="text-sm font-semibold flex items-center gap-1">
{user.is_admin ? (
<span className="text-rose-600 flex items-center gap-1">
@@ -186,169 +376,373 @@ export function ProfileMain() {
</div>
</div>
</div>
<Separator className="border-dashed" />
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1.5">
<BookOpen className="size-3 text-muted-foreground/70" />
个人简介
</div>
<div className="text-sm text-foreground/80 leading-relaxed max-w-2xl bg-muted/20 border border-dashed rounded-lg p-3">
{user.bio || '这个人很懒,什么都没有留下。'}
</div>
</div>
</div>
</div>
</div>
{/* 修改密码面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-500">
<Lock className="size-4" />
</div>
{/* 下方左右并排布局容器 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 修改密码面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
<div>
<h2 className="text-lg font-semibold tracking-tight">修改密码</h2>
<p className="text-xs text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
</div>
</div>
{user.need_change_password && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-3 text-xs text-amber-500 flex items-start gap-2.5">
<Info className="size-4 shrink-0 mt-0.5" />
<div>
<p className="font-semibold">密码风险提示</p>
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
为了账号安全,您必须修改初始密码。
</p>
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-500">
<Lock className="size-4" />
</div>
<div>
<h2 className="text-base font-semibold tracking-tight">修改密码</h2>
<p className="text-[11px] text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
</div>
</div>
</div>
)}
<form onSubmit={handlePasswordChange} className="space-y-4 pt-2 max-w-md">
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
<Input
type="password"
placeholder="请输入当前密码"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">新密码</label>
<Input
type="password"
placeholder="新密码(至少 8 位)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground">确认新密码</label>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
</div>
<Button
type="submit"
className="w-full sm:w-auto"
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? "提交中..." : "确认修改"}
</Button>
</form>
</div>
{/* 账号绑定面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Link2 className="size-4" />
</div>
<div>
<h2 className="text-lg font-semibold tracking-tight">第三方账号绑定</h2>
<p className="text-xs text-muted-foreground">管理并关联您的第三方授权账户,便于快捷登录与验证</p>
</div>
</div>
{/* 已绑定账号列表 */}
<div className="space-y-3 pt-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
{externalAccountBindingsQuery.isPending ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-indigo-500" />
</div>
) : (externalAccountBindingsQuery.data ?? []).length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(externalAccountBindingsQuery.data ?? []).map((binding) => (
<div
key={binding.id}
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 transition-all duration-300"
>
<div className="space-y-1">
<span className="font-semibold text-xs text-foreground block">{binding.auth_source_label}</span>
<span className="text-xs text-muted-foreground font-mono block truncate max-w-[180px]">
{binding.external_username || binding.email || "未提供账号标识"}
</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg h-8 px-2.5 transition-colors"
onClick={async () => {
await AuthService.deleteExternalAccountBinding(binding.id)
await queryClient.invalidateQueries({ queryKey: ["auth", "external-accounts"] })
toast.success("绑定已移除")
}}
>
<Unlink className="size-3.5 mr-1" />
解除绑定
</Button>
{user.need_change_password && (
<div className="mt-4 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-2.5 text-xs text-amber-500 flex items-start gap-2.5">
<Info className="size-4 shrink-0 mt-0.5" />
<div>
<p className="font-semibold">密码风险提示</p>
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
为了账号安全,您必须修改初始密码。
</p>
</div>
))}
</div>
) : (
<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">
<Link2 className="size-6 text-muted-foreground/30 mb-2" />
暂无绑定的第三方账号
</div>
)}
</div>
)}
<form onSubmit={handlePasswordChange} className="space-y-3 pt-4">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
<Input
type="password"
placeholder="请输入当前密码"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="h-8 text-xs rounded-lg"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">新密码</label>
<Input
type="password"
placeholder="新密码(至少 8 位)"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="h-8 text-xs rounded-lg"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">确认新密码</label>
<Input
type="password"
placeholder="确认新密码"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="h-8 text-xs rounded-lg"
required
/>
</div>
<div className="pt-2">
<Button
type="submit"
size="sm"
className="w-full sm:w-auto h-8 text-xs"
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? "提交中..." : "确认修改"}
</Button>
</div>
</form>
</div>
</div>
<Separator className="border-dashed" />
{/* 账号绑定面板 */}
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
<div>
<div className="border-b pb-4 flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
<Link2 className="size-4" />
</div>
<div>
<h2 className="text-base font-semibold tracking-tight">第三方账号绑定</h2>
<p className="text-[11px] text-muted-foreground">管理并关联您的第三方授权账户,便于快捷登录与验证</p>
</div>
</div>
{/* 绑定新账号列表 */}
<div className="space-y-3">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
{publicAuthSourcesQuery.isPending ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-5 animate-spin text-indigo-500" />
{/* 已绑定账号列表 */}
<div className="space-y-2 pt-4">
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
{externalAccountBindingsQuery.isPending ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-4 animate-spin text-indigo-500" />
</div>
) : (externalAccountBindingsQuery.data ?? []).length > 0 ? (
<div className="space-y-2">
{(externalAccountBindingsQuery.data ?? []).map((binding) => (
<div
key={binding.id}
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-3 bg-card hover:bg-muted/10 transition-all duration-300"
>
<div className="space-y-0.5">
<span className="font-semibold text-xs text-foreground block">{binding.auth_source_label}</span>
<span className="text-[10px] text-muted-foreground font-mono block truncate max-w-[150px]">
{binding.external_username || binding.email || "未提供账号标识"}
</span>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="text-[11px] text-muted-foreground hover:text-rose-500 hover:bg-rose-500/10 rounded-lg h-7 px-2 transition-colors"
onClick={async () => {
await AuthService.deleteExternalAccountBinding(binding.id)
await queryClient.invalidateQueries({ queryKey: ["auth", "external-accounts"] })
toast.success("绑定已移除")
}}
>
<Unlink className="size-3 mr-1" />
解绑
</Button>
</div>
))}
</div>
) : (
<div className="rounded-xl border border-dashed border-border/50 px-4 py-6 text-center text-[11px] text-muted-foreground bg-muted/5 flex flex-col items-center justify-center">
<Link2 className="size-5 text-muted-foreground/30 mb-1" />
暂无绑定的第三方账号
</div>
)}
</div>
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{(publicAuthSourcesQuery.data ?? []).map((source) => (
<Button
key={source.id}
type="button"
variant="outline"
className="flex items-center justify-between w-full border border-dashed rounded-xl px-4 py-3.5 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-auto"
onClick={() => {
void bindSourceMutation.mutateAsync(source.name)
}}
>
<div className="flex items-center gap-2">
<Link2 className="size-3.5 text-muted-foreground group-hover:text-indigo-500" />
<span>绑定 {source.display_name || source.name}</span>
</div>
<ArrowRight className="size-3.5 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
</Button>
))}
<Separator className="border-dashed my-4" />
{/* 绑定新账号列表 */}
<div className="space-y-2">
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
{publicAuthSourcesQuery.isPending ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="size-4 animate-spin text-indigo-500" />
</div>
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
<div className="grid grid-cols-1 gap-2">
{(publicAuthSourcesQuery.data ?? []).map((source) => (
<Button
key={source.id}
type="button"
variant="outline"
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-8"
onClick={() => {
void bindSourceMutation.mutateAsync(source.name)
}}
>
<div className="flex items-center gap-1.5">
<Link2 className="size-3 text-muted-foreground group-hover:text-indigo-500" />
<span>绑定 {source.display_name || source.name}</span>
</div>
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
</Button>
))}
</div>
) : (
<div className="text-[11px] text-muted-foreground text-center py-2">
暂无可用第三方认证源
</div>
)}
</div>
) : (
<div className="text-xs text-muted-foreground text-center py-4">
暂无可用第三方认证源
</div>
)}
</div>
</div>
</div>
{/* 编辑资料 Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>编辑个人资料</DialogTitle>
<DialogDescription>修改您的基本信息,保存后立即生效。</DialogDescription>
</DialogHeader>
<form onSubmit={handleProfileSubmit} className="space-y-4">
{/* 上传头像域 */}
<div className="flex flex-col items-center gap-2 py-2">
<div
className="group relative cursor-pointer rounded-full border border-dashed hover:border-primary/50 transition-all"
onClick={() => fileInputRef.current?.click()}
>
<Avatar className="size-20 border-2 border-primary/5 shadow-md">
<AvatarImage src={avatarUrl} alt={nickname} />
<AvatarFallback className="text-xl bg-indigo-600 text-white font-bold">
{(nickname || "U").slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Camera className="size-5 text-white" />
</div>
</div>
<span className="text-[10px] text-muted-foreground">点击上传/修改头像</span>
<input
type="file"
ref={fileInputRef}
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="nickname" className="text-xs">昵称</Label>
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="请输入您的昵称"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="email" className="text-xs">邮箱</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="请输入您的邮箱"
/>
</div>
<div className="space-y-1">
<Label htmlFor="phone" className="text-xs">手机号码</Label>
<Input
id="phone"
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="请输入手机号"
/>
</div>
<div className="space-y-1">
<Label htmlFor="gender" className="text-xs">性别</Label>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger id="gender" size="sm" className="w-full text-xs h-8">
<SelectValue placeholder="选择性别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">男</SelectItem>
<SelectItem value="female">女</SelectItem>
<SelectItem value="secret">保密</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="website" className="text-xs">个人网站</Label>
<Input
id="website"
value={website}
onChange={(e) => setWebsite(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="https://..."
/>
</div>
<div className="space-y-1">
<Label htmlFor="location" className="text-xs">所在地</Label>
<Input
id="location"
value={location}
onChange={(e) => setLocation(e.target.value)}
className="h-8 text-xs rounded-lg"
placeholder="如:北京"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="bio" className="text-xs">个人简介</Label>
<Textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
className="text-xs min-h-[60px] rounded-lg"
placeholder="介绍一下自己吧..."
/>
</div>
<DialogFooter className="pt-2">
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => setIsEditDialogOpen(false)}
>
取消
</Button>
<Button
type="submit"
size="sm"
className="h-8 text-xs"
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? "保存中..." : "保存修改"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* 头像裁剪 Dialog */}
<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>裁剪头像</DialogTitle>
<DialogDescription>调整头像位置和大小,使其处于居中圆形区域内。</DialogDescription>
</DialogHeader>
{cropFile && (
<div className="flex flex-col items-center gap-4 py-2">
<ImageCrop file={cropFile} aspect={1} onCrop={handleCroppedImage}>
<ImageCropContent className="max-w-sm rounded-lg border border-dashed" />
<div className="mt-3 flex justify-center gap-2">
<ImageCropReset />
<ImageCropApply />
</div>
</ImageCrop>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => {
setIsCropDialogOpen(false)
setCropFile(null)
}}
>
取消
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -3,19 +3,29 @@
import {useEffect, useMemo, useState} from "react"
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
import {
CreditCard,
FileText,
Fingerprint,
FolderOpen,
Globe,
Home,
Info,
Layers,
LayoutList,
Loader2,
Lock,
Mail,
Palette,
Pencil,
Plus,
Server,
Settings,
Shield,
ShieldCheck,
Terminal,
Trash2,
UserPlus
UserPlus,
UserRound
} from "lucide-react"
import {useRouter} from "next/navigation"
import {motion} from "motion/react"
@@ -93,6 +103,34 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
const MENU_GROUPS = [
{
name: "基础菜单",
items: [
{ path: "/home", label: "首页", description: "系统控制台/个人首页", icon: Home },
]
},
{
name: "管理菜单",
items: [
{ path: "/admin/users", label: "用户管理", description: "查看和管理系统用户列表及其状态", icon: UserRound },
{ path: "/admin/files", label: "文件管理", description: "查看用户上传文件并清理多余文件", icon: FolderOpen },
{ path: "/admin/tasks", label: "任务管理", description: "查看和调度系统异步及定时任务", icon: Layers },
{ path: "/admin/system", label: "系统配置", description: "管理和维护系统基础键值对配置", icon: ShieldCheck },
{ path: "/admin/logs", label: "系统日志", description: "查看异步任务执行日志和系统运行情况", icon: Terminal },
{ path: "/admin/settings", label: "系统设置", description: "配置安全验证、邮箱服务及目录显示", icon: Settings, readOnly: true },
]
},
{
name: "文档菜单",
items: [
{ path: "/components", label: "组件库", description: "内置 UI 组件的展示、调试与参考", icon: Palette },
{ path: "/docs/api", label: "接口文档", description: "系统 Swagger 交互式 API 接口文档", icon: CreditCard },
{ path: "/docs/how-to-use", label: "使用文档", description: "面向开发与运营的部署使用指南", icon: FileText },
]
}
] as const
export function SecurityMain() {
const queryClient = useQueryClient()
const { user, loading } = useAuth()
@@ -170,6 +208,7 @@ export function SecurityMain() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("系统安全配置已更新")
},
onError: (error: Error) => {
@@ -177,6 +216,39 @@ export function SecurityMain() {
},
})
const menuDisplayConfig = useMemo(() => {
const raw = configs["menu_display_config"]?.value
if (!raw) return {} as Record<string, boolean>
try {
return JSON.parse(raw) as Record<string, boolean>
} catch {
return {} as Record<string, boolean>
}
}, [configs])
const updateMenuConfigMutation = useMutation({
mutationFn: async ({ path, enabled }: { path: string; enabled: boolean }) => {
const newConfig = { ...menuDisplayConfig, [path]: enabled }
const currentCfg = configs["menu_display_config"]
await AdminService.updateSystemConfig("menu_display_config", {
value: JSON.stringify(newConfig),
description: currentCfg?.description || "目录显示配置(JSON 字符串,格式为 {url: enabled})",
})
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
toast.success("目录显示配置已更新")
},
onError: (error: Error) => {
toast.error(error.message || "更新配置失败")
},
})
const handleMenuToggle = (path: string, checked: boolean) => {
updateMenuConfigMutation.mutate({ path, enabled: checked })
}
const toggleSourceMutation = useMutation({
mutationFn: async (source: AuthSource) => {
await AdminService.toggleAuthSource(source.id, { is_active: !source.is_active })
@@ -828,7 +900,79 @@ export function SecurityMain() {
<TabsContent value="status" className="pt-4">
<SystemStatusManager />
</TabsContent>
<TabsContent value="other" />
<TabsContent value="other" className="pt-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-indigo-500/10 text-indigo-500">
<LayoutList className="size-4" />
</div>
<div>
<CardTitle className="text-base font-semibold">目录显示管理</CardTitle>
<CardDescription className="text-xs">
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{MENU_GROUPS.map((group) => (
<div key={group.name} className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-muted-foreground tracking-wider uppercase">
{group.name}
</span>
<div className="h-px bg-border/40 flex-1" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{group.items.map((item) => {
const Icon = item.icon
const isReadOnly = "readOnly" in item && item.readOnly
const checked = menuDisplayConfig[item.path] !== false
return (
<div
key={item.path}
className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm"
>
<div className="space-y-1.5 flex-1 min-w-0 pr-2">
<div className="flex items-center gap-2">
{Icon && <Icon className="size-4 text-indigo-500 shrink-0" />}
<span className="font-medium text-sm text-foreground truncate">{item.label}</span>
{isReadOnly && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0">
不可隐藏
</span>
)}
</div>
<p className="text-xs text-muted-foreground leading-normal line-clamp-2">
{item.description}
</p>
</div>
<div className="flex items-center">
<Switch
checked={checked}
disabled={isReadOnly || updateMenuConfigMutation.isPending}
onCheckedChange={(val) => handleMenuToggle(item.path, val)}
/>
</div>
</div>
)
})}
</div>
</div>
))}
<div className="p-3.5 rounded-lg border border-dashed border-indigo-500/20 bg-indigo-500/5 flex items-start gap-2.5">
<Info className="size-4 text-indigo-500 shrink-0 mt-0.5" />
<div className="text-xs text-muted-foreground leading-relaxed">
<span className="font-semibold text-foreground">安全提示:</span>
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="info" className="pt-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card className="border border-dashed shadow-sm">
+74 -53
View File
@@ -61,6 +61,7 @@ import {
} from "lucide-react"
import {useUser} from "@/contexts/user-context"
import {usePublicConfig} from "@/hooks/use-public-config"
/* 导航数据 */
const data = {
@@ -96,6 +97,7 @@ const data = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
const { user, logout } = useUser()
const { config } = usePublicConfig()
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
const pathname = usePathname()
@@ -142,6 +144,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
}
const navMainFiltered = React.useMemo(() => {
const displayConfig = config?.menu_display_config || {}
return data.navMain.filter((item) => displayConfig[item.url] !== false)
}, [config])
const adminFiltered = React.useMemo(() => {
const displayConfig = config?.menu_display_config || {}
return data.admin.filter((item) => displayConfig[item.url] !== false)
}, [config])
const documentFiltered = React.useMemo(() => {
const displayConfig = config?.menu_display_config || {}
return data.document.filter((item) => displayConfig[item.url] !== false)
}, [config])
return (
<>
{hasAvatarDecoration && showEffect && BackgroundEffect && <BackgroundEffect />}
@@ -263,35 +280,37 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</DropdownMenu>
</SidebarHeader>
<SidebarContent className="group-data-[collapsible=icon]">
<SidebarGroup className="py-0">
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.navMain.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{navMainFiltered.length > 0 && (
<SidebarGroup className="py-0">
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{navMainFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{user?.is_admin && (
{user?.is_admin && adminFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
管理
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.admin.map((item) => (
{adminFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
@@ -310,37 +329,39 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarGroup>
)}
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
文档库
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{data.document.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
{item.external ? (
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
<ArrowUpRight className="size-3 text-muted-foreground" />
</Link>
) : (
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
</Link>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{documentFiltered.length > 0 && (
<SidebarGroup className="py-0 pt-4">
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
文档库
</SidebarGroupLabel>
<SidebarGroupContent className="py-1">
<SidebarMenu className="gap-1">
{documentFiltered.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
tooltip={item.title}
isActive={pathname === item.url}
asChild
>
{item.external ? (
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
<ArrowUpRight className="size-3 text-muted-foreground" />
</Link>
) : (
<Link href={item.url} onClick={handleCloseSidebar}>
{item.icon && <item.icon />}
<span className="flex-1">{item.title}</span>
</Link>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
</SidebarContent>
<SidebarFooter className="mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden">
+337
View File
@@ -0,0 +1,337 @@
"use client"
import {CropIcon, RotateCcwIcon} from "lucide-react"
import {Slot} from "@radix-ui/react-slot"
import {
type ComponentProps,
createContext,
type CSSProperties,
type MouseEvent,
type ReactNode,
type RefObject,
type SyntheticEvent,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react"
import ReactCrop, {
centerCrop,
makeAspectCrop,
type PercentCrop,
type PixelCrop,
type ReactCropProps,
} from "react-image-crop"
import {Button} from "@/components/ui/button"
import {cn} from "@/lib/utils"
import "react-image-crop/dist/ReactCrop.css"
const centerAspectCrop = (
mediaWidth: number,
mediaHeight: number,
aspect: number | undefined,
): PercentCrop =>
centerCrop(
aspect
? makeAspectCrop(
{
unit: "%",
width: 90,
},
aspect,
mediaWidth,
mediaHeight,
)
: { x: 0, y: 0, width: 90, height: 90, unit: "%" },
mediaWidth,
mediaHeight,
)
const getCroppedPngImage = async (
imageSrc: HTMLImageElement,
scaleFactor: number,
pixelCrop: PixelCrop,
maxImageSize: number,
): Promise<string> => {
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
if (!ctx) {
throw new Error("Context is null, this should never happen.")
}
const scaleX = imageSrc.naturalWidth / imageSrc.width
const scaleY = imageSrc.naturalHeight / imageSrc.height
ctx.imageSmoothingEnabled = false
canvas.width = pixelCrop.width
canvas.height = pixelCrop.height
ctx.drawImage(
imageSrc,
pixelCrop.x * scaleX,
pixelCrop.y * scaleY,
pixelCrop.width * scaleX,
pixelCrop.height * scaleY,
0,
0,
canvas.width,
canvas.height,
)
const croppedImageUrl = canvas.toDataURL("image/png")
const response = await fetch(croppedImageUrl)
const blob = await response.blob()
if (blob.size > maxImageSize) {
return await getCroppedPngImage(imageSrc, scaleFactor * 0.9, pixelCrop, maxImageSize)
}
return croppedImageUrl
}
interface ImageCropContextType {
file: File
maxImageSize: number
imgSrc: string
crop: PercentCrop | undefined
completedCrop: PixelCrop | null
imgRef: RefObject<HTMLImageElement | null>
onCrop?: (croppedImage: string) => void
reactCropProps: Omit<ReactCropProps, "onChange" | "onComplete" | "children">
handleChange: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => void
handleComplete: (pixelCrop: PixelCrop, percentCrop: PercentCrop) => Promise<void>
onImageLoad: (e: SyntheticEvent<HTMLImageElement>) => void
applyCrop: () => Promise<void>
resetCrop: () => void
}
const ImageCropContext = createContext<ImageCropContextType | null>(null)
const useImageCrop = () => {
const context = useContext(ImageCropContext)
if (!context) {
throw new Error("ImageCrop components must be used within ImageCrop")
}
return context
}
export type ImageCropProps = {
file: File
maxImageSize?: number
onCrop?: (croppedImage: string) => void
children: ReactNode
onChange?: ReactCropProps["onChange"]
onComplete?: ReactCropProps["onComplete"]
} & Omit<ReactCropProps, "onChange" | "onComplete" | "children">
export const ImageCrop = ({
file,
maxImageSize = 1024 * 1024 * 5,
onCrop,
children,
onChange,
onComplete,
...reactCropProps
}: ImageCropProps) => {
const imgRef = useRef<HTMLImageElement | null>(null)
const [imgSrc, setImgSrc] = useState<string>("")
const [crop, setCrop] = useState<PercentCrop>()
const [completedCrop, setCompletedCrop] = useState<PixelCrop | null>(null)
const [initialCrop, setInitialCrop] = useState<PercentCrop>()
useEffect(() => {
const reader = new FileReader()
reader.addEventListener("load", () => setImgSrc(reader.result?.toString() || ""))
reader.readAsDataURL(file)
}, [file])
const onImageLoad = useCallback(
(e: SyntheticEvent<HTMLImageElement>) => {
const { width, height } = e.currentTarget
const newCrop = centerAspectCrop(width, height, reactCropProps.aspect)
setCrop(newCrop)
setInitialCrop(newCrop)
},
[reactCropProps.aspect],
)
const handleChange = (pixelCrop: PixelCrop, percentCrop: PercentCrop) => {
setCrop(percentCrop)
onChange?.(pixelCrop, percentCrop)
}
const handleComplete = async (pixelCrop: PixelCrop, percentCrop: PercentCrop) => {
setCompletedCrop(pixelCrop)
onComplete?.(pixelCrop, percentCrop)
}
const applyCrop = async () => {
if (!(imgRef.current && completedCrop)) {
return
}
const croppedImage = await getCroppedPngImage(imgRef.current, 1, completedCrop, maxImageSize)
onCrop?.(croppedImage)
}
const resetCrop = () => {
if (initialCrop) {
setCrop(initialCrop)
setCompletedCrop(null)
}
}
const contextValue: ImageCropContextType = {
file,
maxImageSize,
imgSrc,
crop,
completedCrop,
imgRef,
onCrop,
reactCropProps,
handleChange,
handleComplete,
onImageLoad,
applyCrop,
resetCrop,
}
return <ImageCropContext.Provider value={contextValue}>{children}</ImageCropContext.Provider>
}
export interface ImageCropContentProps {
style?: CSSProperties
className?: string
}
export const ImageCropContent = ({ style, className }: ImageCropContentProps) => {
const { imgSrc, crop, handleChange, handleComplete, onImageLoad, imgRef, reactCropProps } =
useImageCrop()
const shadcnStyle = {
"--rc-border-color": "var(--color-border)",
"--rc-focus-color": "var(--color-primary)",
} as CSSProperties
return (
<ReactCrop
className={cn("max-h-[277px] max-w-full", className)}
crop={crop}
onChange={handleChange}
onComplete={handleComplete}
style={{ ...shadcnStyle, ...style }}
{...reactCropProps}
>
{imgSrc && (
<img
alt="crop"
className="size-full"
height={400}
onLoad={onImageLoad}
ref={imgRef}
src={imgSrc}
width={400}
/>
)}
</ReactCrop>
)
}
export type ImageCropApplyProps = ComponentProps<"button"> & {
asChild?: boolean
}
export const ImageCropApply = ({
asChild = false,
children,
onClick,
...props
}: ImageCropApplyProps) => {
const { applyCrop } = useImageCrop()
const handleClick = async (e: MouseEvent<HTMLButtonElement>) => {
await applyCrop()
onClick?.(e)
}
if (asChild) {
return (
<Slot onClick={handleClick} {...(props as any)}>
{children}
</Slot>
)
}
return (
<Button onClick={handleClick} size="icon" variant="ghost" {...(props as any)}>
{children ?? <CropIcon className="size-4" />}
</Button>
)
}
export type ImageCropResetProps = ComponentProps<"button"> & {
asChild?: boolean
}
export const ImageCropReset = ({
asChild = false,
children,
onClick,
...props
}: ImageCropResetProps) => {
const { resetCrop } = useImageCrop()
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
resetCrop()
onClick?.(e)
}
if (asChild) {
return (
<Slot onClick={handleClick} {...(props as any)}>
{children}
</Slot>
)
}
return (
<Button onClick={handleClick} size="icon" variant="ghost" {...(props as any)}>
{children ?? <RotateCcwIcon className="size-4" />}
</Button>
)
}
// Keep the original Cropper component for backward compatibility
export type CropperProps = Omit<ReactCropProps, "onChange"> & {
file: File
maxImageSize?: number
onCrop?: (croppedImage: string) => void
onChange?: ReactCropProps["onChange"]
}
export const Cropper = ({
onChange,
onComplete,
onCrop,
style,
className,
file,
maxImageSize,
...props
}: CropperProps) => (
<ImageCrop
file={file}
maxImageSize={maxImageSize}
onChange={onChange}
onComplete={onComplete}
onCrop={onCrop}
{...(props as any)}
>
<ImageCropContent className={className} style={style} />
</ImageCrop>
)
+10 -59
View File
@@ -1,69 +1,20 @@
import { useState, useEffect } from 'react'
import { ConfigService, PublicConfigResponse } from '@/lib/services'
/* 全局缓存 */
let cachedConfig: PublicConfigResponse | null = null
let cachePromise: Promise<PublicConfigResponse> | null = null
let cacheTime = 0
const CACHE_DURATION = 5 * 60 * 1000
import {useQuery} from '@tanstack/react-query'
import {ConfigService} from '@/lib/services'
/**
* 获取公共配置(带缓存)
*
* 获取公共配置(使用 React Query 进行统一缓存与自动同步)
*
* @example
* ```tsx
* const { config, loading, error } = usePublicConfig()
* ```
*/
export function usePublicConfig() {
const [config, setConfig] = useState<PublicConfigResponse | null>(cachedConfig)
const [loading, setLoading] = useState(!cachedConfig)
const [error, setError] = useState<Error | null>(null)
const { data: config, isLoading: loading, error } = useQuery({
queryKey: ['public-config'],
queryFn: () => ConfigService.getPublicConfig(),
staleTime: 5 * 60 * 1000, // 5 分钟缓存
})
useEffect(() => {
let mounted = true
const fetchConfig = async () => {
const now = Date.now()
if (cachedConfig && (now - cacheTime) < CACHE_DURATION) {
if (mounted) {
setConfig(cachedConfig)
setLoading(false)
}
return
}
if (!cachePromise) {
cachePromise = ConfigService.getPublicConfig()
}
try {
const data = await cachePromise
if (mounted) {
cachedConfig = data
cacheTime = now
setConfig(data)
setError(null)
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err : new Error('获取配置失败'))
}
} finally {
cachePromise = null
if (mounted) {
setLoading(false)
}
}
}
fetchConfig()
return () => {
mounted = false
}
}, [])
return { config, loading, error }
return { config: config || null, loading, error }
}
@@ -9,6 +9,7 @@ import type {
OAuthCallbackResult,
OAuthLoginUrlResponse,
RegisterRequest,
UpdateProfileRequest,
User,
} from './types';
@@ -119,6 +120,10 @@ export class AuthService extends BaseService {
return this.post<void>('/user/change-password', request);
}
static async updateProfile(request: UpdateProfileRequest): Promise<User> {
return this.put<User>('/user/profile', request);
}
static async getExternalAccountBindings(): Promise<ExternalAccountBinding[]> {
return this.get<ExternalAccountBinding[]>('/oauth/external-accounts');
}
+1
View File
@@ -37,4 +37,5 @@ export type {
AuthSource,
ExternalAccountBinding,
ChangePasswordRequest,
UpdateProfileRequest,
} from './types';
+23
View File
@@ -14,6 +14,29 @@ export interface User {
is_admin: boolean;
/** 是否需要修改密码 */
need_change_password?: boolean;
/** 邮箱 */
email: string;
/** 个人简介 */
bio?: string;
/** 手机号码 */
phone?: string;
/** 性别 */
gender?: string;
/** 个人网站 */
website?: string;
/** 所在地 */
location?: string;
}
export interface UpdateProfileRequest {
nickname: string;
email: string;
avatar_url: string;
bio?: string;
phone?: string;
gender?: string;
website?: string;
location?: string;
}
/**
+2
View File
@@ -24,4 +24,6 @@ export interface PublicConfigResponse {
email_login_verification_enabled: boolean;
/** 是否启用邮箱注册验证 */
email_register_verification_enabled: boolean;
/** 目录显示配置 */
menu_display_config?: Record<string, boolean>;
}
+1 -1
View File
@@ -82,7 +82,7 @@ export type {
// 认证服务
export { AuthService } from './auth';
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, AuthSource, ExternalAccountBinding, ChangePasswordRequest } from './auth';
export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, AuthSource, ExternalAccountBinding, ChangePasswordRequest, UpdateProfileRequest } from './auth';
// 配置服务
export { ConfigService } from './config';
+1
View File
@@ -63,6 +63,7 @@
"react-dom": "19.2.3",
"react-easy-crop": "^5.5.6",
"react-hook-form": "^7.68.0",
"react-image-crop": "^11.0.10",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"recharts": "2.15.4",
+12
View File
@@ -158,6 +158,9 @@ importers:
react-hook-form:
specifier: ^7.68.0
version: 7.68.0(react@19.2.3)
react-image-crop:
specifier: ^11.0.10
version: 11.0.10(react@19.2.3)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.2)(react@19.2.3)
@@ -3231,6 +3234,11 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-image-crop@11.0.10:
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
peerDependencies:
react: '>=16.13.1'
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -7060,6 +7068,10 @@ snapshots:
dependencies:
react: 19.2.3
react-image-crop@11.0.10(react@19.2.3):
dependencies:
react: 19.2.3
react-is@16.13.1: {}
react-is@18.3.1: {}
@@ -147,9 +147,9 @@ func TestListSystemConfigs(t *testing.T) {
var configs []model.SystemConfig
json.Unmarshal(dataBytes, &configs)
// Defaults seed 21 configurations
if len(configs) != 21 {
t.Errorf("expected 21 default configs, got %d", len(configs))
// Defaults seed 22 configurations
if len(configs) != 22 {
t.Errorf("expected 22 default configs, got %d", len(configs))
}
})
+18 -11
View File
@@ -27,17 +27,18 @@ import (
// PublicConfigResponse 公共配置响应
type PublicConfigResponse struct {
UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名
SiteName string `json:"site_name"` // 站点名称
PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录
RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册
PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册
OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证
CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算
EmailLoginVerificationEnabled bool `json:"email_login_verification_enabled"` // 是否启用邮箱登录验证
EmailRegisterVerificationEnabled bool `json:"email_register_verification_enabled"` // 是否启用邮箱注册验证
UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名
SiteName string `json:"site_name"` // 站点名称
PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录
RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册
PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册
OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证
CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算
EmailLoginVerificationEnabled bool `json:"email_login_verification_enabled"` // 是否启用邮箱登录验证
EmailRegisterVerificationEnabled bool `json:"email_register_verification_enabled"` // 是否启用邮箱注册验证
MenuDisplayConfig map[string]bool `json:"menu_display_config"` // 目录显示配置
}
// GetPublicConfig 获取公共配置
@@ -116,6 +117,11 @@ func GetPublicConfig(c *gin.Context) {
emailRegisterVerificationEnabled = val
}
menuDisplayConfig, err := model.GetMenuDisplayConfig(ctx)
if err != nil {
menuDisplayConfig = make(map[string]bool)
}
response := PublicConfigResponse{
UploadAllowedExtensions: uploadExtensions,
SiteName: siteName,
@@ -128,6 +134,7 @@ func GetPublicConfig(c *gin.Context) {
CapAutoSolve: capAutoSolve,
EmailLoginVerificationEnabled: emailLoginVerificationEnabled,
EmailRegisterVerificationEnabled: emailRegisterVerificationEnabled,
MenuDisplayConfig: menuDisplayConfig,
}
c.JSON(http.StatusOK, util.OK(response))
+12
View File
@@ -30,9 +30,15 @@ type BasicUserInfo struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarUrl string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password"`
Bio string `json:"bio"`
Phone string `json:"phone"`
Gender string `json:"gender"`
Website string `json:"website"`
Location string `json:"location"`
}
func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
@@ -40,9 +46,15 @@ func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
AvatarUrl: user.AvatarUrl,
IsAdmin: user.IsAdmin,
NeedChangePassword: needChange,
Bio: user.Bio,
Phone: user.Phone,
Gender: user.Gender,
Website: user.Website,
Location: user.Location,
}
}
+85
View File
@@ -549,3 +549,88 @@ func SendEmailCode(c *gin.Context) {
c.JSON(http.StatusOK, util.OKNil())
}
type updateProfileRequest struct {
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarUrl string `json:"avatar_url"`
Bio string `json:"bio"`
Phone string `json:"phone"`
Gender string `json:"gender"`
Website string `json:"website"`
Location string `json:"location"`
}
// UpdateProfile 修改当前登录用户的个人资料
// @Summary 修改当前登录用户的个人资料
// @Description 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。
// @Tags user
// @Accept json
// @Produce json
// @Param request body user.updateProfileRequest true "更新请求参数"
// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "修改成功,返回更新后的用户信息"
// @Failure 400 {object} util.ResponseAny "邮箱已被占用或参数错误"
// @Failure 401 {object} util.ResponseAny "未登录"
// @Router /api/v1/user/profile [put]
func UpdateProfile(c *gin.Context) {
var req updateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, util.Err(err.Error()))
return
}
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
if userObj == nil {
c.JSON(http.StatusUnauthorized, util.Err("请先登录"))
return
}
ctx := c.Request.Context()
var dbUser model.User
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
c.JSON(http.StatusOK, util.Err("未找到该用户"))
return
}
// 校验邮箱格式与唯一性
req.Email = strings.TrimSpace(req.Email)
if req.Email != "" && req.Email != dbUser.Email {
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
c.JSON(http.StatusOK, util.Err("邮箱格式不正确"))
return
}
var count int64
if err := db.DB(ctx).Model(&model.User{}).Where("email = ? AND id != ?", req.Email, dbUser.ID).Count(&count).Error; err != nil {
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
if count > 0 {
c.JSON(http.StatusOK, util.Err("该邮箱已被其他账号绑定"))
return
}
}
// 更新字段
dbUser.Nickname = strings.TrimSpace(req.Nickname)
if dbUser.Nickname == "" {
dbUser.Nickname = dbUser.Username
}
dbUser.Email = req.Email
dbUser.AvatarUrl = req.AvatarUrl
dbUser.Bio = req.Bio
dbUser.Phone = strings.TrimSpace(req.Phone)
dbUser.Gender = strings.TrimSpace(req.Gender)
dbUser.Website = strings.TrimSpace(req.Website)
dbUser.Location = strings.TrimSpace(req.Location)
if err := db.DB(ctx).Save(&dbUser).Error; err != nil {
c.JSON(http.StatusOK, util.Err(err.Error()))
return
}
session := sessions.Default(c)
needChange := session.Get("need_change_password") == true
c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&dbUser, needChange)))
}
+7
View File
@@ -100,6 +100,7 @@ func initSystemConfigs() {
ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)")
ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)")
ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)")
ensureConfigKeyExists(model.ConfigKeyMenuDisplayConfig, "{}", "system", "目录显示配置(JSON 字符串,格式为 {url: enabled})")
return
}
@@ -230,6 +231,12 @@ func initSystemConfigs() {
Type: "system",
Description: "是否开启邮箱注册验证(true/false)",
},
{
Key: model.ConfigKeyMenuDisplayConfig,
Value: "{}",
Type: "system",
Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})",
},
}
if err := tx.Create(&defaultConfigs).Error; err != nil {
+21
View File
@@ -19,6 +19,7 @@ package model
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
@@ -53,6 +54,7 @@ const (
ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证
ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证
ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证
ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串)
)
const (
@@ -144,3 +146,22 @@ func GetBoolByKey(ctx context.Context, key string) (bool, error) {
return value, nil
}
// GetMenuDisplayConfig 获取目录显示配置,解析为 map[string]bool
func GetMenuDisplayConfig(ctx context.Context) (map[string]bool, error) {
var sc SystemConfig
if err := sc.GetByKey(ctx, ConfigKeyMenuDisplayConfig); err != nil {
return nil, err
}
config := make(map[string]bool)
if sc.Value == "" || sc.Value == "{}" {
return config, nil
}
if err := json.Unmarshal([]byte(sc.Value), &config); err != nil {
return nil, fmt.Errorf("解析目录显示配置失败: %w", err)
}
return config, nil
}
+5
View File
@@ -63,6 +63,11 @@ type User struct {
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
IsActive bool `json:"is_active" gorm:"default:true;index"`
IsAdmin bool `json:"is_admin" gorm:"default:false"`
Bio string `json:"bio" gorm:"size:500"`
Phone string `json:"phone" gorm:"size:32"`
Gender string `json:"gender" gorm:"size:16"`
Website string `json:"website" gorm:"size:255"`
Location string `json:"location" gorm:"size:255"`
LastLoginAt time.Time `json:"last_login_at" gorm:"index"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
+1
View File
@@ -151,6 +151,7 @@ func Serve() {
userRouter.GET("/logout", user.Logout)
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
userRouter.PUT("/profile", oauth.LoginRequired(), user.UpdateProfile)
// Access Token
tokenRouter := userRouter.Group("/access-tokens")
+6
View File
@@ -212,6 +212,12 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
Type: "system",
Description: "是否开启邮箱注册验证(true/false)",
},
{
Key: model.ConfigKeyMenuDisplayConfig,
Value: "{}",
Type: "system",
Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})",
},
}
if err := tx.Create(&defaultConfigs).Error; err != nil {