mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 00:56:37 +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 {AnimatePresence, motion} from "motion/react"
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
|
Eye,
|
||||||
FileArchive,
|
FileArchive,
|
||||||
FileAudio,
|
FileAudio,
|
||||||
FileImage,
|
FileImage,
|
||||||
@@ -21,6 +22,8 @@ import {toast} from "sonner"
|
|||||||
import {Button} from "@/components/ui/button"
|
import {Button} from "@/components/ui/button"
|
||||||
import {Input} from "@/components/ui/input"
|
import {Input} from "@/components/ui/input"
|
||||||
import {Badge} from "@/components/ui/badge"
|
import {Badge} from "@/components/ui/badge"
|
||||||
|
import {Checkbox} from "@/components/ui/checkbox"
|
||||||
|
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -31,6 +34,7 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog"
|
} 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 {formatFileSize, UploadService} from "@/lib/services/upload/upload.service"
|
||||||
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
|
import type {Upload as UploadRecord} from "@/lib/services/upload/types"
|
||||||
|
|
||||||
@@ -63,8 +67,9 @@ export function FilesMain() {
|
|||||||
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
|
const [debouncedKeyword, setDebouncedKeyword] = React.useState("")
|
||||||
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set())
|
||||||
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
|
const [deleteTarget, setDeleteTarget] = React.useState<UploadRecord | null>(null)
|
||||||
|
const [detailTarget, setDetailTarget] = React.useState<UploadRecord | null>(null)
|
||||||
const [page, setPage] = React.useState(1)
|
const [page, setPage] = React.useState(1)
|
||||||
const pageSize = 24
|
const pageSize = 15 // 表格视图下 15 条更紧凑
|
||||||
|
|
||||||
// 搜索防抖
|
// 搜索防抖
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -85,6 +90,25 @@ export function FilesMain() {
|
|||||||
const total = listQuery.data?.total ?? 0
|
const total = listQuery.data?.total ?? 0
|
||||||
const totalPages = Math.ceil(total / pageSize)
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => UploadService.deleteFile(id),
|
mutationFn: (id: string) => UploadService.deleteFile(id),
|
||||||
@@ -147,7 +171,7 @@ export function FilesMain() {
|
|||||||
文件管理
|
文件管理
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
管理您上传的所有文件,支持下载 and 批量操作
|
管理您上传的所有文件,支持下载与批量操作
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +251,7 @@ export function FilesMain() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文件网格 */}
|
{/* 文件列表 Table */}
|
||||||
{listQuery.isPending ? (
|
{listQuery.isPending ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<Loader2 className="size-6 animate-spin text-sky-500" />
|
<Loader2 className="size-6 animate-spin text-sky-500" />
|
||||||
@@ -240,86 +264,119 @@ export function FilesMain() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
|
<div className="border border-dashed rounded-xl bg-card overflow-hidden">
|
||||||
{files.map((file, idx) => {
|
<Table>
|
||||||
const isSelected = selectedIds.has(file.id)
|
<TableHeader>
|
||||||
return (
|
<TableRow className="hover:bg-transparent border-dashed">
|
||||||
<motion.div
|
<TableHead className="w-[50px] pl-4 py-3">
|
||||||
key={file.id}
|
<Checkbox
|
||||||
initial={{ opacity: 0, y: 10 }}
|
checked={isAllSelected || (isSomeSelected ? "indeterminate" : false)}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
onCheckedChange={handleSelectAll}
|
||||||
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 ${
|
</TableHead>
|
||||||
isSelected
|
<TableHead className="w-[80px] py-3">预览</TableHead>
|
||||||
? "bg-sky-500/8 border-sky-500/40 ring-1 ring-sky-500/30"
|
<TableHead className="w-[180px] py-3">ID</TableHead>
|
||||||
: "bg-card hover:border-muted-foreground/25"
|
<TableHead className="py-3">文件名</TableHead>
|
||||||
}`}
|
<TableHead className="max-w-[200px] truncate py-3">路径</TableHead>
|
||||||
onClick={() => toggleSelect(file.id)}
|
<TableHead className="w-[100px] py-3">业务类别</TableHead>
|
||||||
>
|
<TableHead className="w-[125px] py-3">MIME类型</TableHead>
|
||||||
{/* 选中指示 */}
|
<TableHead className="w-[100px] py-3">大小</TableHead>
|
||||||
{isSelected && (
|
<TableHead className="w-[150px] py-3">上传时间</TableHead>
|
||||||
<div className="absolute top-2 left-2 size-4 rounded-full bg-sky-500 flex items-center justify-center z-10">
|
<TableHead className="w-[120px] text-right pr-4 py-3">操作</TableHead>
|
||||||
<svg className="size-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
</TableRow>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
</TableHeader>
|
||||||
</svg>
|
<TableBody>
|
||||||
</div>
|
{files.map((file) => {
|
||||||
)}
|
const isSelected = selectedIds.has(file.id)
|
||||||
|
return (
|
||||||
{/* 文件图标 / 图片预览 */}
|
<TableRow
|
||||||
<div className="flex items-center justify-center h-14 rounded-lg bg-muted/30 overflow-hidden">
|
key={file.id}
|
||||||
{file.mime_type.startsWith("image/") ? (
|
className={`border-dashed hover:bg-muted/30 transition-colors ${
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
isSelected ? "bg-sky-500/5 hover:bg-sky-500/10" : ""
|
||||||
<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)}
|
|
||||||
>
|
>
|
||||||
<Download className="size-3" />
|
<TableCell className="pl-4 py-3">
|
||||||
</Button>
|
<Checkbox
|
||||||
<Button
|
checked={isSelected}
|
||||||
size="icon"
|
onCheckedChange={() => toggleSelect(file.id)}
|
||||||
variant="secondary"
|
/>
|
||||||
className="h-6 w-6 rounded-md text-destructive hover:bg-destructive/10"
|
</TableCell>
|
||||||
title="删除"
|
<TableCell className="py-3">
|
||||||
onClick={() => setDeleteTarget(file)}
|
<div className="flex items-center justify-center size-9 rounded-lg bg-muted/40 overflow-hidden border">
|
||||||
>
|
{file.mime_type.startsWith("image/") ? (
|
||||||
<Trash2 className="size-3" />
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
</Button>
|
<img
|
||||||
</div>
|
src={`/f/${file.id}`}
|
||||||
</motion.div>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -350,6 +407,112 @@ export function FilesMain() {
|
|||||||
</div>
|
</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 */}
|
{/* 删除确认 Dialog */}
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -14,11 +14,40 @@ import {
|
|||||||
BreadcrumbSeparator
|
BreadcrumbSeparator
|
||||||
} from "@/components/ui/breadcrumb"
|
} from "@/components/ui/breadcrumb"
|
||||||
import {useUser} from "@/contexts/user-context"
|
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 {Button} from "@/components/ui/button"
|
||||||
import {Input} from "@/components/ui/input"
|
import {Input} from "@/components/ui/input"
|
||||||
import {Separator} from "@/components/ui/separator"
|
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 {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"
|
import {toast} from "sonner"
|
||||||
|
|
||||||
export function ProfileMain() {
|
export function ProfileMain() {
|
||||||
@@ -27,10 +56,41 @@ export function ProfileMain() {
|
|||||||
const isAnimatingRef = React.useRef(false)
|
const isAnimatingRef = React.useRef(false)
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// 修改密码 State
|
||||||
const [oldPassword, setOldPassword] = React.useState("")
|
const [oldPassword, setOldPassword] = React.useState("")
|
||||||
const [newPassword, setNewPassword] = React.useState("")
|
const [newPassword, setNewPassword] = React.useState("")
|
||||||
const [confirmPassword, setConfirmPassword] = 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({
|
const changePasswordMutation = useMutation({
|
||||||
mutationFn: (req: any) => AuthService.changePassword(req),
|
mutationFn: (req: any) => AuthService.changePassword(req),
|
||||||
onSuccess: () => {
|
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({
|
const externalAccountBindingsQuery = useQuery({
|
||||||
queryKey: ["auth", "external-accounts"],
|
queryKey: ["auth", "external-accounts"],
|
||||||
queryFn: () => AuthService.getExternalAccountBindings(),
|
queryFn: () => AuthService.getExternalAccountBindings(),
|
||||||
@@ -96,9 +182,38 @@ export function ProfileMain() {
|
|||||||
}, 650)
|
}, 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) {
|
if (loading) {
|
||||||
return (
|
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">
|
<div className="border-b border-border pb-4">
|
||||||
<h1 className="text-2xl font-semibold">个人资料</h1>
|
<h1 className="text-2xl font-semibold">个人资料</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +223,7 @@ export function ProfileMain() {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
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 className="text-sm text-muted-foreground">未找到用户信息</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -132,10 +247,22 @@ export function ProfileMain() {
|
|||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 基本资料面板 */}
|
||||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
|
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
|
||||||
<div className="border-b pb-4">
|
<div className="border-b pb-4 flex justify-between items-center">
|
||||||
<h2 className="text-lg font-semibold tracking-tight">基本资料</h2>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">您的个人账户基本信息</p>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6 pt-2">
|
<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="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="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||||
<div className="space-y-1">
|
<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 className="text-sm font-semibold">@{user.username}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<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 className="text-sm font-semibold">{user.nickname || '未设置'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<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 className="text-sm font-mono font-semibold">{user.id}</div>
|
||||||
</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="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">
|
<div className="text-sm font-semibold flex items-center gap-1">
|
||||||
{user.is_admin ? (
|
{user.is_admin ? (
|
||||||
<span className="text-rose-600 flex items-center gap-1">
|
<span className="text-rose-600 flex items-center gap-1">
|
||||||
@@ -186,169 +376,373 @@ export function ProfileMain() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 修改密码面板 */}
|
{/* 下方左右并排布局容器 */}
|
||||||
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-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">
|
<div className="space-y-6 bg-card border border-dashed rounded-lg p-6 flex flex-col justify-between">
|
||||||
<Lock className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold tracking-tight">修改密码</h2>
|
<div className="border-b pb-4 flex items-center gap-2">
|
||||||
<p className="text-xs text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
|
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-500">
|
||||||
</div>
|
<Lock className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
{user.need_change_password && (
|
<h2 className="text-base font-semibold tracking-tight">修改密码</h2>
|
||||||
<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">
|
<p className="text-[11px] text-muted-foreground">更改您的账号密码以确保安全。密码长度不能少于 8 位。</p>
|
||||||
<Info className="size-4 shrink-0 mt-0.5" />
|
</div>
|
||||||
<div>
|
|
||||||
<p className="font-semibold">密码风险提示</p>
|
|
||||||
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
|
|
||||||
为了账号安全,您必须修改初始密码。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handlePasswordChange} className="space-y-4 pt-2 max-w-md">
|
{user.need_change_password && (
|
||||||
<div className="space-y-2">
|
<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">
|
||||||
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
|
<Info className="size-4 shrink-0 mt-0.5" />
|
||||||
<Input
|
<div>
|
||||||
type="password"
|
<p className="font-semibold">密码风险提示</p>
|
||||||
placeholder="请输入当前密码"
|
<p className="mt-0.5 text-amber-500/80 leading-relaxed font-normal">
|
||||||
value={oldPassword}
|
为了账号安全,您必须修改初始密码。
|
||||||
onChange={(e) => setOldPassword(e.target.value)}
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
) : (
|
|
||||||
<div className="rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center">
|
<form onSubmit={handlePasswordChange} className="space-y-3 pt-4">
|
||||||
<Link2 className="size-6 text-muted-foreground/30 mb-2" />
|
<div className="space-y-1.5">
|
||||||
暂无绑定的第三方账号
|
<label className="text-xs font-medium text-muted-foreground">当前密码</label>
|
||||||
</div>
|
<Input
|
||||||
)}
|
type="password"
|
||||||
|
placeholder="请输入当前密码"
|
||||||
|
value={oldPassword}
|
||||||
|
onChange={(e) => setOldPassword(e.target.value)}
|
||||||
|
className="h-8 text-xs rounded-lg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">新密码</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="新密码(至少 8 位)"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
className="h-8 text-xs rounded-lg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">确认新密码</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
placeholder="确认新密码"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
className="h-8 text-xs rounded-lg"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="w-full sm:w-auto h-8 text-xs"
|
||||||
|
disabled={changePasswordMutation.isPending}
|
||||||
|
>
|
||||||
|
{changePasswordMutation.isPending ? "提交中..." : "确认修改"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<div className="space-y-2 pt-4">
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
|
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">已绑定账号</h3>
|
||||||
{publicAuthSourcesQuery.isPending ? (
|
{externalAccountBindingsQuery.isPending ? (
|
||||||
<div className="flex items-center justify-center py-6">
|
<div className="flex items-center justify-center py-4">
|
||||||
<Loader2 className="size-5 animate-spin text-indigo-500" />
|
<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>
|
</div>
|
||||||
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<Separator className="border-dashed my-4" />
|
||||||
{(publicAuthSourcesQuery.data ?? []).map((source) => (
|
|
||||||
<Button
|
{/* 绑定新账号列表 */}
|
||||||
key={source.id}
|
<div className="space-y-2">
|
||||||
type="button"
|
<h3 className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">绑定新账号</h3>
|
||||||
variant="outline"
|
{publicAuthSourcesQuery.isPending ? (
|
||||||
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"
|
<div className="flex items-center justify-center py-4">
|
||||||
onClick={() => {
|
<Loader2 className="size-4 animate-spin text-indigo-500" />
|
||||||
void bindSourceMutation.mutateAsync(source.name)
|
</div>
|
||||||
}}
|
) : (publicAuthSourcesQuery.data ?? []).length > 0 ? (
|
||||||
>
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<div className="flex items-center gap-2">
|
{(publicAuthSourcesQuery.data ?? []).map((source) => (
|
||||||
<Link2 className="size-3.5 text-muted-foreground group-hover:text-indigo-500" />
|
<Button
|
||||||
<span>绑定 {source.display_name || source.name}</span>
|
key={source.id}
|
||||||
</div>
|
type="button"
|
||||||
<ArrowRight className="size-3.5 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
|
variant="outline"
|
||||||
</Button>
|
className="flex items-center justify-between w-full border border-dashed rounded-xl px-3 py-2 text-left font-normal text-xs hover:bg-indigo-500/5 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-300 group h-8"
|
||||||
))}
|
onClick={() => {
|
||||||
|
void bindSourceMutation.mutateAsync(source.name)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Link2 className="size-3 text-muted-foreground group-hover:text-indigo-500" />
|
||||||
|
<span>绑定 {source.display_name || source.name}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-indigo-500" />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-muted-foreground text-center py-2">
|
||||||
|
暂无可用第三方认证源
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="text-xs text-muted-foreground text-center py-4">
|
|
||||||
暂无可用第三方认证源
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑资料 Dialog */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑个人资料</DialogTitle>
|
||||||
|
<DialogDescription>修改您的基本信息,保存后立即生效。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleProfileSubmit} className="space-y-4">
|
||||||
|
{/* 上传头像域 */}
|
||||||
|
<div className="flex flex-col items-center gap-2 py-2">
|
||||||
|
<div
|
||||||
|
className="group relative cursor-pointer rounded-full border border-dashed hover:border-primary/50 transition-all"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<Avatar className="size-20 border-2 border-primary/5 shadow-md">
|
||||||
|
<AvatarImage src={avatarUrl} alt={nickname} />
|
||||||
|
<AvatarFallback className="text-xl bg-indigo-600 text-white font-bold">
|
||||||
|
{(nickname || "U").slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="absolute inset-0 bg-black/40 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Camera className="size-5 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground">点击上传/修改头像</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="nickname" className="text-xs">昵称</Label>
|
||||||
|
<Input
|
||||||
|
id="nickname"
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
className="h-8 text-xs rounded-lg"
|
||||||
|
placeholder="请输入您的昵称"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="email" className="text-xs">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="h-8 text-xs rounded-lg"
|
||||||
|
placeholder="请输入您的邮箱"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="phone" className="text-xs">手机号码</Label>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className="h-8 text-xs rounded-lg"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="gender" className="text-xs">性别</Label>
|
||||||
|
<Select value={gender} onValueChange={setGender}>
|
||||||
|
<SelectTrigger id="gender" size="sm" className="w-full text-xs h-8">
|
||||||
|
<SelectValue placeholder="选择性别" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="male">男</SelectItem>
|
||||||
|
<SelectItem value="female">女</SelectItem>
|
||||||
|
<SelectItem value="secret">保密</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="website" className="text-xs">个人网站</Label>
|
||||||
|
<Input
|
||||||
|
id="website"
|
||||||
|
value={website}
|
||||||
|
onChange={(e) => setWebsite(e.target.value)}
|
||||||
|
className="h-8 text-xs rounded-lg"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="location" className="text-xs">所在地</Label>
|
||||||
|
<Input
|
||||||
|
id="location"
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
className="h-8 text-xs rounded-lg"
|
||||||
|
placeholder="如:北京"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="bio" className="text-xs">个人简介</Label>
|
||||||
|
<Textarea
|
||||||
|
id="bio"
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => setBio(e.target.value)}
|
||||||
|
className="text-xs min-h-[60px] rounded-lg"
|
||||||
|
placeholder="介绍一下自己吧..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
onClick={() => setIsEditDialogOpen(false)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
disabled={updateProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateProfileMutation.isPending ? "保存中..." : "保存修改"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 头像裁剪 Dialog */}
|
||||||
|
<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>裁剪头像</DialogTitle>
|
||||||
|
<DialogDescription>调整头像位置和大小,使其处于居中圆形区域内。</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{cropFile && (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-2">
|
||||||
|
<ImageCrop file={cropFile} aspect={1} onCrop={handleCroppedImage}>
|
||||||
|
<ImageCropContent className="max-w-sm rounded-lg border border-dashed" />
|
||||||
|
<div className="mt-3 flex justify-center gap-2">
|
||||||
|
<ImageCropReset />
|
||||||
|
<ImageCropApply />
|
||||||
|
</div>
|
||||||
|
</ImageCrop>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setIsCropDialogOpen(false)
|
||||||
|
setCropFile(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,29 @@
|
|||||||
import {useEffect, useMemo, useState} from "react"
|
import {useEffect, useMemo, useState} from "react"
|
||||||
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"
|
||||||
import {
|
import {
|
||||||
|
CreditCard,
|
||||||
|
FileText,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
|
FolderOpen,
|
||||||
Globe,
|
Globe,
|
||||||
|
Home,
|
||||||
Info,
|
Info,
|
||||||
|
Layers,
|
||||||
|
LayoutList,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
Mail,
|
Mail,
|
||||||
|
Palette,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
|
ShieldCheck,
|
||||||
|
Terminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserPlus
|
UserPlus,
|
||||||
|
UserRound
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import {useRouter} from "next/navigation"
|
import {useRouter} from "next/navigation"
|
||||||
import {motion} from "motion/react"
|
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() {
|
export function SecurityMain() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
@@ -170,6 +208,7 @@ export function SecurityMain() {
|
|||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] })
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["public-config"] })
|
||||||
toast.success("系统安全配置已更新")
|
toast.success("系统安全配置已更新")
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
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({
|
const toggleSourceMutation = useMutation({
|
||||||
mutationFn: async (source: AuthSource) => {
|
mutationFn: async (source: AuthSource) => {
|
||||||
await AdminService.toggleAuthSource(source.id, { is_active: !source.is_active })
|
await AdminService.toggleAuthSource(source.id, { is_active: !source.is_active })
|
||||||
@@ -828,7 +900,79 @@ export function SecurityMain() {
|
|||||||
<TabsContent value="status" className="pt-4">
|
<TabsContent value="status" className="pt-4">
|
||||||
<SystemStatusManager />
|
<SystemStatusManager />
|
||||||
</TabsContent>
|
</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">
|
<TabsContent value="info" className="pt-4">
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
<Card className="border border-dashed shadow-sm">
|
<Card className="border border-dashed shadow-sm">
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import {useUser} from "@/contexts/user-context"
|
import {useUser} from "@/contexts/user-context"
|
||||||
|
import {usePublicConfig} from "@/hooks/use-public-config"
|
||||||
|
|
||||||
/* 导航数据 */
|
/* 导航数据 */
|
||||||
const data = {
|
const data = {
|
||||||
@@ -96,6 +97,7 @@ const data = {
|
|||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
|
const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar()
|
||||||
const { user, logout } = useUser()
|
const { user, logout } = useUser()
|
||||||
|
const { config } = usePublicConfig()
|
||||||
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
|
const [showLogoutDialog, setShowLogoutDialog] = React.useState(false)
|
||||||
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
|
const [isLoggingOut, setIsLoggingOut] = React.useState(false)
|
||||||
const pathname = usePathname()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{hasAvatarDecoration && showEffect && BackgroundEffect && <BackgroundEffect />}
|
{hasAvatarDecoration && showEffect && BackgroundEffect && <BackgroundEffect />}
|
||||||
@@ -263,35 +280,37 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent className="group-data-[collapsible=icon]">
|
<SidebarContent className="group-data-[collapsible=icon]">
|
||||||
<SidebarGroup className="py-0">
|
{navMainFiltered.length > 0 && (
|
||||||
<SidebarGroupContent className="py-1">
|
<SidebarGroup className="py-0">
|
||||||
<SidebarMenu className="gap-1">
|
<SidebarGroupContent className="py-1">
|
||||||
{data.navMain.map((item) => (
|
<SidebarMenu className="gap-1">
|
||||||
<SidebarMenuItem key={item.title}>
|
{navMainFiltered.map((item) => (
|
||||||
<SidebarMenuButton
|
<SidebarMenuItem key={item.title}>
|
||||||
tooltip={item.title}
|
<SidebarMenuButton
|
||||||
isActive={pathname === item.url}
|
tooltip={item.title}
|
||||||
asChild
|
isActive={pathname === item.url}
|
||||||
>
|
asChild
|
||||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
>
|
||||||
{item.icon && <item.icon />}
|
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||||
<span>{item.title}</span>
|
{item.icon && <item.icon />}
|
||||||
</Link>
|
<span>{item.title}</span>
|
||||||
</SidebarMenuButton>
|
</Link>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuButton>
|
||||||
))}
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
))}
|
||||||
</SidebarGroupContent>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
{user?.is_admin && (
|
{user?.is_admin && adminFiltered.length > 0 && (
|
||||||
<SidebarGroup className="py-0 pt-4">
|
<SidebarGroup className="py-0 pt-4">
|
||||||
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
|
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
|
||||||
管理
|
管理
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<SidebarGroupContent className="py-1">
|
<SidebarGroupContent className="py-1">
|
||||||
<SidebarMenu className="gap-1">
|
<SidebarMenu className="gap-1">
|
||||||
{data.admin.map((item) => (
|
{adminFiltered.map((item) => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
tooltip={item.title}
|
tooltip={item.title}
|
||||||
@@ -310,37 +329,39 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SidebarGroup className="py-0 pt-4">
|
{documentFiltered.length > 0 && (
|
||||||
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
|
<SidebarGroup className="py-0 pt-4">
|
||||||
文档库
|
<SidebarGroupLabel className="text-xs font-normal text-muted-foreground">
|
||||||
</SidebarGroupLabel>
|
文档库
|
||||||
<SidebarGroupContent className="py-1">
|
</SidebarGroupLabel>
|
||||||
<SidebarMenu className="gap-1">
|
<SidebarGroupContent className="py-1">
|
||||||
{data.document.map((item) => (
|
<SidebarMenu className="gap-1">
|
||||||
<SidebarMenuItem key={item.title}>
|
{documentFiltered.map((item) => (
|
||||||
<SidebarMenuButton
|
<SidebarMenuItem key={item.title}>
|
||||||
tooltip={item.title}
|
<SidebarMenuButton
|
||||||
isActive={pathname === item.url}
|
tooltip={item.title}
|
||||||
asChild
|
isActive={pathname === item.url}
|
||||||
>
|
asChild
|
||||||
{item.external ? (
|
>
|
||||||
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
|
{item.external ? (
|
||||||
{item.icon && <item.icon />}
|
<Link href={item.url} target="_blank" rel="noopener noreferrer" onClick={handleCloseSidebar}>
|
||||||
<span className="flex-1">{item.title}</span>
|
{item.icon && <item.icon />}
|
||||||
<ArrowUpRight className="size-3 text-muted-foreground" />
|
<span className="flex-1">{item.title}</span>
|
||||||
</Link>
|
<ArrowUpRight className="size-3 text-muted-foreground" />
|
||||||
) : (
|
</Link>
|
||||||
<Link href={item.url} onClick={handleCloseSidebar}>
|
) : (
|
||||||
{item.icon && <item.icon />}
|
<Link href={item.url} onClick={handleCloseSidebar}>
|
||||||
<span className="flex-1">{item.title}</span>
|
{item.icon && <item.icon />}
|
||||||
</Link>
|
<span className="flex-1">{item.title}</span>
|
||||||
)}
|
</Link>
|
||||||
</SidebarMenuButton>
|
)}
|
||||||
</SidebarMenuItem>
|
</SidebarMenuButton>
|
||||||
))}
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
))}
|
||||||
</SidebarGroupContent>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
)}
|
||||||
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter className="mt-auto px-3 py-3 group-data-[collapsible=icon]:hidden">
|
<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,14 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import {useQuery} from '@tanstack/react-query'
|
||||||
import { ConfigService, PublicConfigResponse } from '@/lib/services'
|
import {ConfigService} from '@/lib/services'
|
||||||
|
|
||||||
/* 全局缓存 */
|
|
||||||
let cachedConfig: PublicConfigResponse | null = null
|
|
||||||
let cachePromise: Promise<PublicConfigResponse> | null = null
|
|
||||||
let cacheTime = 0
|
|
||||||
const CACHE_DURATION = 5 * 60 * 1000
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取公共配置(带缓存)
|
* 获取公共配置(使用 React Query 进行统一缓存与自动同步)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -16,54 +10,11 @@ const CACHE_DURATION = 5 * 60 * 1000
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function usePublicConfig() {
|
export function usePublicConfig() {
|
||||||
const [config, setConfig] = useState<PublicConfigResponse | null>(cachedConfig)
|
const { data: config, isLoading: loading, error } = useQuery({
|
||||||
const [loading, setLoading] = useState(!cachedConfig)
|
queryKey: ['public-config'],
|
||||||
const [error, setError] = useState<Error | null>(null)
|
queryFn: () => ConfigService.getPublicConfig(),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 分钟缓存
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
return { config: config || null, loading, error }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
OAuthCallbackResult,
|
OAuthCallbackResult,
|
||||||
OAuthLoginUrlResponse,
|
OAuthLoginUrlResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
UpdateProfileRequest,
|
||||||
User,
|
User,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -119,6 +120,10 @@ export class AuthService extends BaseService {
|
|||||||
return this.post<void>('/user/change-password', request);
|
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[]> {
|
static async getExternalAccountBindings(): Promise<ExternalAccountBinding[]> {
|
||||||
return this.get<ExternalAccountBinding[]>('/oauth/external-accounts');
|
return this.get<ExternalAccountBinding[]>('/oauth/external-accounts');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,5 @@ export type {
|
|||||||
AuthSource,
|
AuthSource,
|
||||||
ExternalAccountBinding,
|
ExternalAccountBinding,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
|
UpdateProfileRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|||||||
@@ -14,6 +14,29 @@ export interface User {
|
|||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
/** 是否需要修改密码 */
|
/** 是否需要修改密码 */
|
||||||
need_change_password?: 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_login_verification_enabled: boolean;
|
||||||
/** 是否启用邮箱注册验证 */
|
/** 是否启用邮箱注册验证 */
|
||||||
email_register_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 { 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';
|
export { ConfigService } from './config';
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-easy-crop": "^5.5.6",
|
"react-easy-crop": "^5.5.6",
|
||||||
"react-hook-form": "^7.68.0",
|
"react-hook-form": "^7.68.0",
|
||||||
|
"react-image-crop": "^11.0.10",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
|
|||||||
Generated
+12
@@ -158,6 +158,9 @@ importers:
|
|||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.68.0
|
specifier: ^7.68.0
|
||||||
version: 7.68.0(react@19.2.3)
|
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:
|
react-markdown:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0(@types/react@19.2.2)(react@19.2.3)
|
version: 10.1.0(@types/react@19.2.2)(react@19.2.3)
|
||||||
@@ -3231,6 +3234,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
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:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -7060,6 +7068,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.3
|
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@16.13.1: {}
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ func TestListSystemConfigs(t *testing.T) {
|
|||||||
var configs []model.SystemConfig
|
var configs []model.SystemConfig
|
||||||
json.Unmarshal(dataBytes, &configs)
|
json.Unmarshal(dataBytes, &configs)
|
||||||
|
|
||||||
// Defaults seed 21 configurations
|
// Defaults seed 22 configurations
|
||||||
if len(configs) != 21 {
|
if len(configs) != 22 {
|
||||||
t.Errorf("expected 21 default configs, got %d", len(configs))
|
t.Errorf("expected 22 default configs, got %d", len(configs))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -27,17 +27,18 @@ import (
|
|||||||
|
|
||||||
// PublicConfigResponse 公共配置响应
|
// PublicConfigResponse 公共配置响应
|
||||||
type PublicConfigResponse struct {
|
type PublicConfigResponse struct {
|
||||||
UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名
|
UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名
|
||||||
SiteName string `json:"site_name"` // 站点名称
|
SiteName string `json:"site_name"` // 站点名称
|
||||||
PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录
|
PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录
|
||||||
RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册
|
RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册
|
||||||
PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册
|
PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册
|
||||||
OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录
|
OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录
|
||||||
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
|
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
|
||||||
CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证
|
CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证
|
||||||
CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算
|
CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算
|
||||||
EmailLoginVerificationEnabled bool `json:"email_login_verification_enabled"` // 是否启用邮箱登录验证
|
EmailLoginVerificationEnabled bool `json:"email_login_verification_enabled"` // 是否启用邮箱登录验证
|
||||||
EmailRegisterVerificationEnabled bool `json:"email_register_verification_enabled"` // 是否启用邮箱注册验证
|
EmailRegisterVerificationEnabled bool `json:"email_register_verification_enabled"` // 是否启用邮箱注册验证
|
||||||
|
MenuDisplayConfig map[string]bool `json:"menu_display_config"` // 目录显示配置
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicConfig 获取公共配置
|
// GetPublicConfig 获取公共配置
|
||||||
@@ -116,6 +117,11 @@ func GetPublicConfig(c *gin.Context) {
|
|||||||
emailRegisterVerificationEnabled = val
|
emailRegisterVerificationEnabled = val
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menuDisplayConfig, err := model.GetMenuDisplayConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
menuDisplayConfig = make(map[string]bool)
|
||||||
|
}
|
||||||
|
|
||||||
response := PublicConfigResponse{
|
response := PublicConfigResponse{
|
||||||
UploadAllowedExtensions: uploadExtensions,
|
UploadAllowedExtensions: uploadExtensions,
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
@@ -128,6 +134,7 @@ func GetPublicConfig(c *gin.Context) {
|
|||||||
CapAutoSolve: capAutoSolve,
|
CapAutoSolve: capAutoSolve,
|
||||||
EmailLoginVerificationEnabled: emailLoginVerificationEnabled,
|
EmailLoginVerificationEnabled: emailLoginVerificationEnabled,
|
||||||
EmailRegisterVerificationEnabled: emailRegisterVerificationEnabled,
|
EmailRegisterVerificationEnabled: emailRegisterVerificationEnabled,
|
||||||
|
MenuDisplayConfig: menuDisplayConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK(response))
|
c.JSON(http.StatusOK, util.OK(response))
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ type BasicUserInfo struct {
|
|||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
|
Email string `json:"email"`
|
||||||
AvatarUrl string `json:"avatar_url"`
|
AvatarUrl string `json:"avatar_url"`
|
||||||
IsAdmin bool `json:"is_admin"`
|
IsAdmin bool `json:"is_admin"`
|
||||||
NeedChangePassword bool `json:"need_change_password"`
|
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 {
|
func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
|
||||||
@@ -40,9 +46,15 @@ func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
|
|||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Username: user.Username,
|
Username: user.Username,
|
||||||
Nickname: user.Nickname,
|
Nickname: user.Nickname,
|
||||||
|
Email: user.Email,
|
||||||
AvatarUrl: user.AvatarUrl,
|
AvatarUrl: user.AvatarUrl,
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
NeedChangePassword: needChange,
|
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())
|
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.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)")
|
||||||
ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)")
|
ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)")
|
||||||
ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)")
|
ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)")
|
||||||
|
ensureConfigKeyExists(model.ConfigKeyMenuDisplayConfig, "{}", "system", "目录显示配置(JSON 字符串,格式为 {url: enabled})")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +231,12 @@ func initSystemConfigs() {
|
|||||||
Type: "system",
|
Type: "system",
|
||||||
Description: "是否开启邮箱注册验证(true/false)",
|
Description: "是否开启邮箱注册验证(true/false)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: model.ConfigKeyMenuDisplayConfig,
|
||||||
|
Value: "{}",
|
||||||
|
Type: "system",
|
||||||
|
Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Create(&defaultConfigs).Error; err != nil {
|
if err := tx.Create(&defaultConfigs).Error; err != nil {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -53,6 +54,7 @@ const (
|
|||||||
ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证
|
ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证
|
||||||
ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证
|
ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证
|
||||||
ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证
|
ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证
|
||||||
|
ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串)
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -144,3 +146,22 @@ func GetBoolByKey(ctx context.Context, key string) (bool, error) {
|
|||||||
|
|
||||||
return value, nil
|
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"`
|
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
|
||||||
IsActive bool `json:"is_active" gorm:"default:true;index"`
|
IsActive bool `json:"is_active" gorm:"default:true;index"`
|
||||||
IsAdmin bool `json:"is_admin" gorm:"default:false"`
|
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"`
|
LastLoginAt time.Time `json:"last_login_at" gorm:"index"`
|
||||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
|
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime;index"`
|
||||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
|
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ func Serve() {
|
|||||||
userRouter.GET("/logout", user.Logout)
|
userRouter.GET("/logout", user.Logout)
|
||||||
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
||||||
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
||||||
|
userRouter.PUT("/profile", oauth.LoginRequired(), user.UpdateProfile)
|
||||||
|
|
||||||
// Access Token
|
// Access Token
|
||||||
tokenRouter := userRouter.Group("/access-tokens")
|
tokenRouter := userRouter.Group("/access-tokens")
|
||||||
|
|||||||
@@ -212,6 +212,12 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
|||||||
Type: "system",
|
Type: "system",
|
||||||
Description: "是否开启邮箱注册验证(true/false)",
|
Description: "是否开启邮箱注册验证(true/false)",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: model.ConfigKeyMenuDisplayConfig,
|
||||||
|
Value: "{}",
|
||||||
|
Type: "system",
|
||||||
|
Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Create(&defaultConfigs).Error; err != nil {
|
if err := tx.Create(&defaultConfigs).Error; err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user