mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
个人信息页面增强
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -37,4 +37,5 @@ export type {
|
||||
AuthSource,
|
||||
ExternalAccountBinding,
|
||||
ChangePasswordRequest,
|
||||
UpdateProfileRequest,
|
||||
} from './types';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,4 +24,6 @@ export interface PublicConfigResponse {
|
||||
email_login_verification_enabled: boolean;
|
||||
/** 是否启用邮箱注册验证 */
|
||||
email_register_verification_enabled: boolean;
|
||||
/** 目录显示配置 */
|
||||
menu_display_config?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+12
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user