From c5656813ae22c570a27cf498f85992375c3a183f Mon Sep 17 00:00:00 2001 From: charilezhou Date: Mon, 19 Jan 2026 13:48:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /files 文件管理页面 - 添加 FilesTable 组件支持分页、筛选、删除 - 添加 FileStats 组件展示统计信息 - 扩展 file.service.ts 添加列表和统计接口 - 新增 useFiles、useFileStats、useDeleteFile hooks Co-Authored-By: Claude Opus 4.5 --- apps/web/src/app/(dashboard)/files/page.tsx | 38 ++ apps/web/src/components/files/FileStats.tsx | 78 ++++ apps/web/src/components/files/FilesTable.tsx | 354 +++++++++++++++++++ apps/web/src/hooks/useFileUrl.ts | 6 +- apps/web/src/hooks/useFiles.ts | 96 +++++ apps/web/src/services/file.service.ts | 129 ++++--- 6 files changed, 655 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/files/page.tsx create mode 100644 apps/web/src/components/files/FileStats.tsx create mode 100644 apps/web/src/components/files/FilesTable.tsx create mode 100644 apps/web/src/hooks/useFiles.ts diff --git a/apps/web/src/app/(dashboard)/files/page.tsx b/apps/web/src/app/(dashboard)/files/page.tsx new file mode 100644 index 0000000..827ffd8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/files/page.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { FilesTable } from '@/components/files/FilesTable'; +import { FileStats } from '@/components/files/FileStats'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; + +export default function FilesPage() { + return ( +
+
+
+

文件管理

+

管理系统中的所有上传文件。

+
+
+ + {/* 文件统计卡片 */} + + + {/* 文件列表 */} + + + 文件列表 + 查看和管理所有上传的文件,支持筛选和删除操作。 + + + + + +
+ ); +} diff --git a/apps/web/src/components/files/FileStats.tsx b/apps/web/src/components/files/FileStats.tsx new file mode 100644 index 0000000..b988e97 --- /dev/null +++ b/apps/web/src/components/files/FileStats.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { File, HardDrive, Image, Paperclip } from 'lucide-react'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useFileStats } from '@/hooks/useFiles'; + +interface StatCardProps { + title: string; + value: string | number; + description?: string; + icon: React.ReactNode; + isLoading?: boolean; +} + +function StatCard({ title, value, description, icon, isLoading }: StatCardProps) { + return ( + + + {title} +
{icon}
+
+ + {isLoading ? ( + + ) : ( +
{value}
+ )} + {description &&

{description}

} +
+
+ ); +} + +export function FileStats() { + const { data: stats, isLoading } = useFileStats(); + + // 计算各用途文件数 + const avatarCount = stats?.byPurpose?.avatar ?? 0; + const attachmentCount = stats?.byPurpose?.attachment ?? 0; + + // 计算图片文件数 + const imageCount = Object.entries(stats?.byMimeType ?? {}) + .filter(([mimeType]) => mimeType.startsWith('image/')) + .reduce((sum, [, count]) => sum + count, 0); + + return ( +
+ } + isLoading={isLoading} + /> + } + isLoading={isLoading} + /> + } + isLoading={isLoading} + /> + } + isLoading={isLoading} + /> +
+ ); +} diff --git a/apps/web/src/components/files/FilesTable.tsx b/apps/web/src/components/files/FilesTable.tsx new file mode 100644 index 0000000..0e7ed47 --- /dev/null +++ b/apps/web/src/components/files/FilesTable.tsx @@ -0,0 +1,354 @@ +'use client'; + +import { formatDate } from '@seclusion/shared'; +import type { ColumnDef } from '@tanstack/react-table'; +import { MoreHorizontal, Trash2, ExternalLink, Image, File, FileText } from 'lucide-react'; +import { useState, useCallback } from 'react'; +import { toast } from 'sonner'; + +import { + DataTable, + DataTableColumnHeader, + type PaginationState, + type SortingParams, + type SearchConfig, + type SearchParams, +} from '@/components/shared/DataTable'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { PAGINATION } from '@/config/constants'; +import { useFiles, useDeleteFile, useFileStats } from '@/hooks/useFiles'; +import type { FileResponse, FilePurpose } from '@/services/file.service'; +import { fileService } from '@/services/file.service'; + +// 用途显示名称映射 +const PURPOSE_LABELS: Record = { + avatar: '头像', + attachment: '附件', +}; + +// 用途徽章颜色映射 +const PURPOSE_VARIANTS: Record = { + avatar: 'default', + attachment: 'secondary', +}; + +// 根据 MIME 类型获取图标 +function getFileIcon(mimeType: string) { + if (mimeType.startsWith('image/')) { + return ; + } + if (mimeType.includes('pdf')) { + return ; + } + return ; +} + +// 格式化文件大小 +function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${units[i]}`; +} + +interface FileActionsProps { + file: FileResponse; + onDelete: (id: string) => void; + onPreview: (id: string) => void; +} + +function FileActions({ file, onDelete, onPreview }: FileActionsProps) { + return ( + + + + + + 操作 + + onPreview(file.id)}> + + 预览/下载 + + onDelete(file.id)} className="text-destructive"> + + 删除 + + + + ); +} + +export function FilesTable() { + // 分页状态 + const [pagination, setPagination] = useState({ + page: PAGINATION.DEFAULT_PAGE, + pageSize: PAGINATION.DEFAULT_PAGE_SIZE, + }); + + // 排序状态 + const [sorting, setSorting] = useState({}); + + // 搜索状态 + const [searchParams, setSearchParams] = useState({}); + + // 对话框状态 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [fileToDelete, setFileToDelete] = useState(null); + + // 查询参数 + const queryParams = { + page: pagination.page, + pageSize: pagination.pageSize, + purpose: searchParams.purpose as FilePurpose | undefined, + mimeType: searchParams.mimeType as string | undefined, + ...sorting, + }; + + // 查询 + const { data: filesData, isLoading, refetch } = useFiles(queryParams); + const { data: statsData } = useFileStats(); + + // 变更 + const deleteFile = useDeleteFile(); + + const handleDelete = useCallback((id: string) => { + setFileToDelete(id); + setDeleteDialogOpen(true); + }, []); + + const confirmDelete = useCallback(async () => { + if (!fileToDelete) return; + + try { + await deleteFile.mutateAsync(fileToDelete); + toast.success('文件已删除'); + } catch (error) { + toast.error(error instanceof Error ? error.message : '删除失败'); + } finally { + setDeleteDialogOpen(false); + setFileToDelete(null); + } + }, [fileToDelete, deleteFile]); + + const handlePreview = useCallback(async (id: string) => { + try { + const { url } = await fileService.getFileUrl(id); + window.open(url, '_blank'); + } catch (error) { + toast.error(error instanceof Error ? error.message : '获取文件链接失败'); + } + }, []); + + // 分页变化处理 + const handlePaginationChange = useCallback((newPagination: PaginationState) => { + setPagination(newPagination); + }, []); + + // 排序变化处理 + const handleSortingChange = useCallback((newSorting: SortingParams) => { + setSorting(newSorting); + setPagination((prev) => ({ ...prev, page: 1 })); + }, []); + + // 搜索变化处理 + const handleSearchChange = useCallback((params: SearchParams) => { + setSearchParams(params); + setPagination((prev) => ({ ...prev, page: 1 })); + }, []); + + // 文件搜索配置 + const fileSearchConfig: SearchConfig = { + fields: [ + { + type: 'select', + key: 'purpose', + label: '用途', + placeholder: '全部用途', + options: [ + { value: 'avatar', label: '头像' }, + { value: 'attachment', label: '附件' }, + ], + }, + { + type: 'select', + key: 'mimeType', + label: '类型', + placeholder: '全部类型', + options: [ + { value: 'image/', label: '图片' }, + { value: 'image/jpeg', label: 'JPEG' }, + { value: 'image/png', label: 'PNG' }, + { value: 'image/gif', label: 'GIF' }, + { value: 'image/webp', label: 'WebP' }, + ], + }, + ], + columns: 4, + }; + + // 列定义 + const columns: ColumnDef[] = [ + { + accessorKey: 'id', + header: 'ID', + cell: ({ row }) => ( + + {row.original.id.slice(0, 8)}... + + ), + }, + { + accessorKey: 'filename', + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {getFileIcon(row.original.mimeType)} + + {row.original.filename} + +
+ ), + }, + { + accessorKey: 'mimeType', + header: '类型', + cell: ({ row }) => ( + {row.original.mimeType} + ), + }, + { + accessorKey: 'size', + header: ({ column }) => ( + + ), + cell: ({ row }) => formatFileSize(row.original.size), + }, + { + accessorKey: 'purpose', + header: '用途', + cell: ({ row }) => ( + + {PURPOSE_LABELS[row.original.purpose] || row.original.purpose} + + ), + }, + { + accessorKey: 'createdAt', + header: ({ column }) => ( + + ), + cell: ({ row }) => formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'), + }, + { + id: 'actions', + header: '操作', + cell: ({ row }) => ( + + ), + }, + ]; + + return ( +
+ {/* 工具栏 */} +
+
+
+ 共 {filesData?.total ?? 0} 个文件 + {statsData && ( + <> + ,占用空间{' '} + {statsData.totalSizeFormatted} + + )} +
+
+ +
+ + {/* 数据表格 */} + + + {/* 删除确认弹窗 */} + + + + 确认删除 + + 确定要删除该文件吗?此操作不可恢复。 + + + + 取消 + + 删除 + + + + +
+ ); +} diff --git a/apps/web/src/hooks/useFileUrl.ts b/apps/web/src/hooks/useFileUrl.ts index 850a16f..d894653 100644 --- a/apps/web/src/hooks/useFileUrl.ts +++ b/apps/web/src/hooks/useFileUrl.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { getFileUrl } from '@/services/file.service'; +import { fileService } from '@/services/file.service'; /** * 获取文件临时访问 URL 的 Hook @@ -8,9 +8,9 @@ import { getFileUrl } from '@/services/file.service'; export function useFileUrl(fileId: string | null | undefined) { return useQuery({ queryKey: ['file-url', fileId], - queryFn: () => getFileUrl(fileId!), + queryFn: () => fileService.getFileUrl(fileId!), enabled: !!fileId, - // URL 有效期 5 分钟,提前 1 分钟刷新 + // URL 有效期 5 ���钟,提前 1 分钟刷新 staleTime: 4 * 60 * 1000, refetchInterval: 4 * 60 * 1000, }); diff --git a/apps/web/src/hooks/useFiles.ts b/apps/web/src/hooks/useFiles.ts new file mode 100644 index 0000000..cc6633d --- /dev/null +++ b/apps/web/src/hooks/useFiles.ts @@ -0,0 +1,96 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { + fileService, + type GetFilesParams, + type FilePurpose, +} from '@/services/file.service'; +import { useIsAuthenticated } from '@/stores/authStore'; + +/** + * 文件相关的 Query Keys + */ +export const fileKeys = { + all: ['files'] as const, + lists: () => [...fileKeys.all, 'list'] as const, + list: (params: GetFilesParams) => [...fileKeys.lists(), params] as const, + details: () => [...fileKeys.all, 'detail'] as const, + detail: (id: string) => [...fileKeys.details(), id] as const, + stats: () => [...fileKeys.all, 'stats'] as const, + urls: () => [...fileKeys.all, 'url'] as const, + url: (id: string) => [...fileKeys.urls(), id] as const, +}; + +/** + * 获取文件列表 + */ +export function useFiles(params: GetFilesParams = {}) { + const isAuthenticated = useIsAuthenticated(); + + return useQuery({ + queryKey: fileKeys.list(params), + queryFn: () => fileService.getFiles(params), + enabled: isAuthenticated, + }); +} + +/** + * 获取文件统计信息 + */ +export function useFileStats() { + const isAuthenticated = useIsAuthenticated(); + + return useQuery({ + queryKey: fileKeys.stats(), + queryFn: () => fileService.getStats(), + enabled: isAuthenticated, + // 统计数据可以缓存稍长时间 + staleTime: 60 * 1000, // 1 分钟 + }); +} + +/** + * 获取单个文件信息 + */ +export function useFile(id: string) { + const isAuthenticated = useIsAuthenticated(); + + return useQuery({ + queryKey: fileKeys.detail(id), + queryFn: () => fileService.getFile(id), + enabled: isAuthenticated && !!id, + }); +} + +/** + * 上传文件 + */ +export function useUploadFile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ file, purpose }: { file: File; purpose: FilePurpose }) => + fileService.upload(file, purpose), + onSuccess: () => { + // 刷新文件列表和统计 + queryClient.invalidateQueries({ queryKey: fileKeys.lists() }); + queryClient.invalidateQueries({ queryKey: fileKeys.stats() }); + }, + }); +} + +/** + * 删除文件 + */ +export function useDeleteFile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (fileId: string) => fileService.deleteFile(fileId), + onSuccess: () => { + // 刷新文件列表和统计 + queryClient.invalidateQueries({ queryKey: fileKeys.lists() }); + queryClient.invalidateQueries({ queryKey: fileKeys.stats() }); + }, + }); +} diff --git a/apps/web/src/services/file.service.ts b/apps/web/src/services/file.service.ts index facd504..3e75e07 100644 --- a/apps/web/src/services/file.service.ts +++ b/apps/web/src/services/file.service.ts @@ -1,5 +1,7 @@ +import type { PaginatedResponse } from '@seclusion/shared'; + import { API_ENDPOINTS } from '@/config/constants'; -import { httpClient, type CustomRequestConfig } from '@/lib/http'; +import { http, httpClient, type CustomRequestConfig } from '@/lib/http'; /** 文件用途 */ export type FilePurpose = 'avatar' | 'attachment'; @@ -20,51 +22,92 @@ export interface PresignedUrlResponse { expiresIn: number; } -/** - * 上传文件 - */ -export async function uploadFile( - file: File, - purpose: FilePurpose, -): Promise { - const formData = new FormData(); - formData.append('file', file); - formData.append('purpose', purpose); +/** 文件统计响应 */ +export interface FileStatsResponse { + totalFiles: number; + totalSize: number; + totalSizeFormatted: string; + byPurpose: Record; + byMimeType: Record; +} - const config: CustomRequestConfig = { - headers: { - 'Content-Type': 'multipart/form-data', - }, - skipEncryption: true, - }; - - const response = await httpClient.post( - `${API_ENDPOINTS.FILES}/upload`, - formData, - config, - ); - - return response.data; +/** 文件列表查询参数 */ +export interface GetFilesParams { + page?: number; + pageSize?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + purpose?: FilePurpose; + uploaderId?: string; + mimeType?: string; } /** - * 获取文件临时访问 URL + * 文件服务 */ -export async function getFileUrl( - fileId: string, - expiresIn?: number, -): Promise { - const params = expiresIn ? { expiresIn } : {}; - const response = await httpClient.get( - `${API_ENDPOINTS.FILES}/${fileId}/url`, - { params }, - ); - return response.data; -} +export const fileService = { + /** + * 获取文件列表(分页) + */ + getFiles: (params: GetFilesParams = {}): Promise> => { + return http.get(API_ENDPOINTS.FILES, { params }); + }, -/** - * 删除文件 - */ -export async function deleteFile(fileId: string): Promise { - await httpClient.delete(`${API_ENDPOINTS.FILES}/${fileId}`); -} + /** + * 获取文件统计信息 + */ + getStats: (): Promise => { + return http.get(`${API_ENDPOINTS.FILES}/stats`); + }, + + /** + * 获取单个文件信息 + */ + getFile: (id: string): Promise => { + return http.get(`${API_ENDPOINTS.FILES}/${id}`); + }, + + /** + * 上传文件 + */ + upload: async (file: File, purpose: FilePurpose): Promise => { + const formData = new FormData(); + formData.append('file', file); + formData.append('purpose', purpose); + + const config: CustomRequestConfig = { + headers: { + 'Content-Type': 'multipart/form-data', + }, + skipEncryption: true, + }; + + const response = await httpClient.post( + `${API_ENDPOINTS.FILES}/upload`, + formData, + config, + ); + + return response.data; + }, + + /** + * 获取文件临时访问 URL + */ + getFileUrl: (fileId: string, expiresIn?: number): Promise => { + const params = expiresIn ? { expiresIn } : {}; + return http.get(`${API_ENDPOINTS.FILES}/${fileId}/url`, { params }); + }, + + /** + * 删除文件 + */ + deleteFile: (fileId: string): Promise => { + return http.delete(`${API_ENDPOINTS.FILES}/${fileId}`); + }, +}; + +// 兼容旧的导出方式 +export const uploadFile = fileService.upload; +export const getFileUrl = fileService.getFileUrl; +export const deleteFile = fileService.deleteFile;