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;