diff --git a/frontend/components/common/admin/files.tsx b/frontend/components/common/admin/files.tsx index aab75b93..2e57e298 100644 --- a/frontend/components/common/admin/files.tsx +++ b/frontend/components/common/admin/files.tsx @@ -5,6 +5,7 @@ import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" import {AnimatePresence, motion} from "motion/react" import { Download, + Eye, FileArchive, FileAudio, FileImage, @@ -21,6 +22,8 @@ import {toast} from "sonner" import {Button} from "@/components/ui/button" import {Input} from "@/components/ui/input" import {Badge} from "@/components/ui/badge" +import {Checkbox} from "@/components/ui/checkbox" +import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table" import { AlertDialog, AlertDialogAction, @@ -31,6 +34,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" +import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle,} from "@/components/ui/sheet" import {formatFileSize, UploadService} from "@/lib/services/upload/upload.service" import type {Upload as UploadRecord} from "@/lib/services/upload/types" @@ -63,8 +67,9 @@ export function FilesMain() { const [debouncedKeyword, setDebouncedKeyword] = React.useState("") const [selectedIds, setSelectedIds] = React.useState>(new Set()) const [deleteTarget, setDeleteTarget] = React.useState(null) + const [detailTarget, setDetailTarget] = React.useState(null) const [page, setPage] = React.useState(1) - const pageSize = 24 + const pageSize = 15 // 表格视图下 15 条更紧凑 // 搜索防抖 React.useEffect(() => { @@ -85,6 +90,25 @@ export function FilesMain() { const total = listQuery.data?.total ?? 0 const totalPages = Math.ceil(total / pageSize) + const isAllSelected = files.length > 0 && files.every((f) => selectedIds.has(f.id)) + const isSomeSelected = files.length > 0 && files.some((f) => selectedIds.has(f.id)) && !isAllSelected + + const handleSelectAll = () => { + if (isAllSelected) { + setSelectedIds((prev) => { + const next = new Set(prev) + files.forEach((f) => next.delete(f.id)) + return next + }) + } else { + setSelectedIds((prev) => { + const next = new Set(prev) + files.forEach((f) => next.add(f.id)) + return next + }) + } + } + // 删除单文件 const deleteMutation = useMutation({ mutationFn: (id: string) => UploadService.deleteFile(id), @@ -147,7 +171,7 @@ export function FilesMain() { 文件管理

- 管理您上传的所有文件,支持下载 and 批量操作 + 管理您上传的所有文件,支持下载与批量操作

@@ -227,7 +251,7 @@ export function FilesMain() { )} - {/* 文件网格 */} + {/* 文件列表 Table */} {listQuery.isPending ? (
@@ -240,86 +264,119 @@ export function FilesMain() {

) : ( -
- {files.map((file, idx) => { - const isSelected = selectedIds.has(file.id) - return ( - toggleSelect(file.id)} - > - {/* 选中指示 */} - {isSelected && ( -
- - - -
- )} - - {/* 文件图标 / 图片预览 */} -
- {file.mime_type.startsWith("image/") ? ( - // eslint-disable-next-line @next/next/no-img-element - {file.file_name} { - ;(e.currentTarget as HTMLImageElement).style.display = "none" - }} - /> - ) : ( - getFileIcon(file.mime_type) - )} -
- - {/* 文件名 */} -

- {file.file_name} -

- - {/* 元数据 */} -
-

{formatFileSize(file.file_size)}

-

{formatDate(file.created_at)}

-
- - {/* Hover 操作按钮 */} -
e.stopPropagation()} - > -
)} + {/* 详情查看 Sheet */} + !open && setDetailTarget(null)}> + + + 文件详情 + 查看文件的完整元数据与配置属性。 + + + {detailTarget && ( +
+ {/* 大图/格式预览 */} +
+ {detailTarget.mime_type.startsWith("image/") ? ( + // eslint-disable-next-line @next/next/no-img-element + {detailTarget.file_name} + ) : ( +
+ {getFileIcon(detailTarget.mime_type, "size-14")} + + {detailTarget.extension} 文件 + +
+ )} +
+ + {/* 元数据列表 */} +
+
+ 文件 ID + {detailTarget.id} +
+ +
+ 文件名 + {detailTarget.file_name} +
+ +
+ 存储路径 + {detailTarget.file_path} +
+ +
+ 大小 + + {formatFileSize(detailTarget.file_size)} ({detailTarget.file_size.toLocaleString()} 字节) + +
+ +
+ MIME 类型 + {detailTarget.mime_type} +
+ +
+ 文件后缀 + {detailTarget.extension} +
+ +
+ Hash (SHA-256) + {detailTarget.hash} +
+ +
+ 业务类型 + + + {detailTarget.type} + + +
+ +
+ 存储驱动 + {detailTarget.storage_driver || "local"} +
+ +
+ 上传者 ID + {detailTarget.user_id} +
+ +
+ 上传时间 + {formatDate(detailTarget.created_at)} +
+ + {detailTarget.metadata && Object.keys(detailTarget.metadata).length > 0 && ( +
+ 额外元数据 +
+                      {JSON.stringify(detailTarget.metadata, null, 2)}
+                    
+
+ )} +
+
+ )} +
+
+ {/* 删除确认 Dialog */} !open && setDeleteTarget(null)}> diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index 76e2af91..4128fc53 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -14,11 +14,40 @@ import { BreadcrumbSeparator } from "@/components/ui/breadcrumb" import {useUser} from "@/contexts/user-context" -import {ArrowRight, Info, Link2, Loader2, Lock, Shield, Unlink} from "lucide-react" +import { + ArrowRight, + BookOpen, + Camera, + Edit, + Globe, + Info, + Link2, + Loader2, + Lock, + Mail, + MapPin, + Phone, + Shield, + Unlink, + User as UserIcon +} from "lucide-react" import {Button} from "@/components/ui/button" import {Input} from "@/components/ui/input" import {Separator} from "@/components/ui/separator" +import {Textarea} from "@/components/ui/textarea" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select" +import {Label} from "@/components/ui/label" import {AuthService} from "@/lib/services" +import {UploadService} from "@/lib/services/upload/upload.service" +import {ImageCrop, ImageCropApply, ImageCropContent, ImageCropReset} from "@/components/ui/image-crop" import {toast} from "sonner" export function ProfileMain() { @@ -27,10 +56,41 @@ export function ProfileMain() { const isAnimatingRef = React.useRef(false) const queryClient = useQueryClient() + // 修改密码 State const [oldPassword, setOldPassword] = React.useState("") const [newPassword, setNewPassword] = React.useState("") const [confirmPassword, setConfirmPassword] = React.useState("") + // 编辑个人资料 State + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + const [nickname, setNickname] = React.useState("") + const [email, setEmail] = React.useState("") + const [bio, setBio] = React.useState("") + const [phone, setPhone] = React.useState("") + const [gender, setGender] = React.useState("secret") + const [website, setWebsite] = React.useState("") + const [location, setLocation] = React.useState("") + const [avatarUrl, setAvatarUrl] = React.useState("") + + // 头像裁剪 State + const [cropFile, setCropFile] = React.useState(null) + const [isCropDialogOpen, setIsCropDialogOpen] = React.useState(false) + const fileInputRef = React.useRef(null) + + // 初始化编辑表单数据 + React.useEffect(() => { + if (user) { + setNickname(user.nickname || "") + setEmail(user.email || "") + setBio(user.bio || "") + setPhone(user.phone || "") + setGender(user.gender || "secret") + setWebsite(user.website || "") + setLocation(user.location || "") + setAvatarUrl(user.avatar_url || "") + } + }, [user, isEditDialogOpen]) + const changePasswordMutation = useMutation({ mutationFn: (req: any) => AuthService.changePassword(req), onSuccess: () => { @@ -61,6 +121,32 @@ export function ProfileMain() { }) } + const updateProfileMutation = useMutation({ + mutationFn: (req: any) => AuthService.updateProfile(req), + onSuccess: () => { + toast.success("个人信息修改成功") + setIsEditDialogOpen(false) + void refetch() + }, + onError: (error: Error) => { + toast.error(error.message || "修改个人信息失败,请重试") + }, + }) + + const handleProfileSubmit = (e: React.FormEvent) => { + e.preventDefault() + updateProfileMutation.mutate({ + nickname, + email, + avatar_url: avatarUrl, + bio, + phone, + gender, + website, + location, + }) + } + const externalAccountBindingsQuery = useQuery({ queryKey: ["auth", "external-accounts"], queryFn: () => AuthService.getExternalAccountBindings(), @@ -96,9 +182,38 @@ export function ProfileMain() { }, 650) } + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + setCropFile(file) + setIsCropDialogOpen(true) + } + } + + const handleCroppedImage = async (croppedBase64: string) => { + try { + const res = await UploadService.uploadBase64Image(croppedBase64, "avatar", "avatar.png") + setAvatarUrl(`/f/${res.id}`) + setIsCropDialogOpen(false) + setCropFile(null) + toast.success("头像上传成功,点击保存以生效") + } catch (err: any) { + toast.error(err.message || "上传头像失败") + } + } + + const getGenderLabel = (g?: string) => { + switch (g) { + case "male": return "男" + case "female": return "女" + case "secret": return "保密" + default: return g || "保密" + } + } + if (loading) { return ( -
+

个人资料

@@ -108,7 +223,7 @@ export function ProfileMain() { if (!user) { return ( -
+
未找到用户信息
) @@ -132,10 +247,22 @@ export function ProfileMain() {
+ {/* 基本资料面板 */}
-
-

基本资料

-

您的个人账户基本信息

+
+
+

基本资料

+

您的个人账户基本信息

+
+
@@ -156,24 +283,87 @@ export function ProfileMain() {
-
账户
+
+ + 账户 +
@{user.username}
-
昵称
+
+ + 昵称 +
{user.nickname || '未设置'}
-
用户ID (UID)
+
+ + 用户ID (UID) +
{user.id}
- +
+
+ + 邮箱 +
+
{user.email || '未绑定'}
+
-
管理员身份
+
+ + 手机号码 +
+
{user.phone || '未设置'}
+
+ +
+
+ + 性别 +
+
{getGenderLabel(user.gender)}
+
+ +
+
+ + 个人网站 +
+
+ {user.website ? ( + + {user.website} + + ) : ( + '未设置' + )} +
+
+ +
+
+ + 所在地 +
+
{user.location || '未设置'}
+
+ +
+
+ + 管理员身份 +
{user.is_admin ? ( @@ -186,169 +376,373 @@ export function ProfileMain() {
+ + + +
+
+ + 个人简介 +
+
+ {user.bio || '这个人很懒,什么都没有留下。'} +
+
- {/* 修改密码面板 */} -
-
-
- -
+ {/* 下方左右并排布局容器 */} +
+ {/* 修改密码面板 */} +
-

修改密码

-

更改您的账号密码以确保安全。密码长度不能少于 8 位。

-
-
- - {user.need_change_password && ( -
- -
-

密码风险提示

-

- 为了账号安全,您必须修改初始密码。 -

+
+
+ +
+
+

修改密码

+

更改您的账号密码以确保安全。密码长度不能少于 8 位。

+
-
- )} -
-
- - setOldPassword(e.target.value)} - required - /> -
-
- - setNewPassword(e.target.value)} - required - /> -
-
- - setConfirmPassword(e.target.value)} - required - /> -
- - -
-
- - {/* 账号绑定面板 */} -
-
-
- -
-
-

第三方账号绑定

-

管理并关联您的第三方授权账户,便于快捷登录与验证

-
-
- - {/* 已绑定账号列表 */} -
-

已绑定账号

- {externalAccountBindingsQuery.isPending ? ( -
- -
- ) : (externalAccountBindingsQuery.data ?? []).length > 0 ? ( -
- {(externalAccountBindingsQuery.data ?? []).map((binding) => ( -
-
- {binding.auth_source_label} - - {binding.external_username || binding.email || "未提供账号标识"} - -
- + {user.need_change_password && ( +
+ +
+

密码风险提示

+

+ 为了账号安全,您必须修改初始密码。 +

- ))} -
- ) : ( -
- - 暂无绑定的第三方账号 -
- )} +
+ )} + +
+
+ + setOldPassword(e.target.value)} + className="h-8 text-xs rounded-lg" + required + /> +
+
+ + setNewPassword(e.target.value)} + className="h-8 text-xs rounded-lg" + required + /> +
+
+ + setConfirmPassword(e.target.value)} + className="h-8 text-xs rounded-lg" + required + /> +
+ +
+ +
+
+
- + {/* 账号绑定面板 */} +
+
+
+
+ +
+
+

第三方账号绑定

+

管理并关联您的第三方授权账户,便于快捷登录与验证

+
+
- {/* 绑定新账号列表 */} -
-

绑定新账号

- {publicAuthSourcesQuery.isPending ? ( -
- + {/* 已绑定账号列表 */} +
+

已绑定账号

+ {externalAccountBindingsQuery.isPending ? ( +
+ +
+ ) : (externalAccountBindingsQuery.data ?? []).length > 0 ? ( +
+ {(externalAccountBindingsQuery.data ?? []).map((binding) => ( +
+
+ {binding.auth_source_label} + + {binding.external_username || binding.email || "未提供账号标识"} + +
+ +
+ ))} +
+ ) : ( +
+ + 暂无绑定的第三方账号 +
+ )}
- ) : (publicAuthSourcesQuery.data ?? []).length > 0 ? ( -
- {(publicAuthSourcesQuery.data ?? []).map((source) => ( - - ))} + + + + {/* 绑定新账号列表 */} +
+

绑定新账号

+ {publicAuthSourcesQuery.isPending ? ( +
+ +
+ ) : (publicAuthSourcesQuery.data ?? []).length > 0 ? ( +
+ {(publicAuthSourcesQuery.data ?? []).map((source) => ( + + ))} +
+ ) : ( +
+ 暂无可用第三方认证源 +
+ )}
- ) : ( -
- 暂无可用第三方认证源 -
- )} +
+ + {/* 编辑资料 Dialog */} + + + + 编辑个人资料 + 修改您的基本信息,保存后立即生效。 + + +
+ {/* 上传头像域 */} +
+
fileInputRef.current?.click()} + > + + + + {(nickname || "U").slice(0, 2).toUpperCase()} + + +
+ +
+
+ 点击上传/修改头像 + +
+ +
+
+ + setNickname(e.target.value)} + className="h-8 text-xs rounded-lg" + placeholder="请输入您的昵称" + required + /> +
+ +
+ + setEmail(e.target.value)} + className="h-8 text-xs rounded-lg" + placeholder="请输入您的邮箱" + /> +
+ +
+ + setPhone(e.target.value)} + className="h-8 text-xs rounded-lg" + placeholder="请输入手机号" + /> +
+ +
+ + +
+ +
+ + setWebsite(e.target.value)} + className="h-8 text-xs rounded-lg" + placeholder="https://..." + /> +
+ +
+ + setLocation(e.target.value)} + className="h-8 text-xs rounded-lg" + placeholder="如:北京" + /> +
+
+ +
+ +