feat(web): 添加文件管理页面

- 新增 /files 文件管理页面
- 添加 FilesTable 组件支持分页、筛选、删除
- 添加 FileStats 组件展示统计信息
- 扩展 file.service.ts 添加列表和统计接口
- 新增 useFiles、useFileStats、useDeleteFile hooks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
charilezhou
2026-01-19 13:48:18 +08:00
parent 0acea22262
commit c5656813ae
6 changed files with 655 additions and 46 deletions

View File

@@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
</div>
</div>
{/* 文件统计卡片 */}
<FileStats />
{/* 文件列表 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<FilesTable />
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<div className="text-muted-foreground">{icon}</div>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-7 w-16" />
) : (
<div className="text-2xl font-bold">{value}</div>
)}
{description && <p className="text-xs text-muted-foreground mt-1">{description}</p>}
</CardContent>
</Card>
);
}
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 (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
title="文件总数"
value={stats?.totalFiles ?? 0}
icon={<File className="h-4 w-4" />}
isLoading={isLoading}
/>
<StatCard
title="存储空间"
value={stats?.totalSizeFormatted ?? '0 B'}
icon={<HardDrive className="h-4 w-4" />}
isLoading={isLoading}
/>
<StatCard
title="头像文件"
value={avatarCount}
description={`占比 ${stats?.totalFiles ? Math.round((avatarCount / stats.totalFiles) * 100) : 0}%`}
icon={<Image className="h-4 w-4" />}
isLoading={isLoading}
/>
<StatCard
title="附件文件"
value={attachmentCount}
description={`图片 ${imageCount}`}
icon={<Paperclip className="h-4 w-4" />}
isLoading={isLoading}
/>
</div>
);
}

View File

@@ -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<string, string> = {
avatar: '头像',
attachment: '附件',
};
// 用途徽章颜色映射
const PURPOSE_VARIANTS: Record<string, 'default' | 'secondary' | 'outline'> = {
avatar: 'default',
attachment: 'secondary',
};
// 根据 MIME 类型获取图标
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) {
return <Image className="h-4 w-4 text-blue-500" />;
}
if (mimeType.includes('pdf')) {
return <FileText className="h-4 w-4 text-red-500" />;
}
return <File className="h-4 w-4 text-gray-500" />;
}
// 格式化文件大小
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only"></span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel></DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onPreview(file.id)}>
<ExternalLink className="mr-2 h-4 w-4" />
/
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDelete(file.id)} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function FilesTable() {
// 分页状态
const [pagination, setPagination] = useState<PaginationState>({
page: PAGINATION.DEFAULT_PAGE,
pageSize: PAGINATION.DEFAULT_PAGE_SIZE,
});
// 排序状态
const [sorting, setSorting] = useState<SortingParams>({});
// 搜索状态
const [searchParams, setSearchParams] = useState<SearchParams>({});
// 对话框状态
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [fileToDelete, setFileToDelete] = useState<string | null>(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<FileResponse> = {
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<FileResponse>[] = [
{
accessorKey: 'id',
header: 'ID',
cell: ({ row }) => (
<span className="font-mono text-xs text-muted-foreground">
{row.original.id.slice(0, 8)}...
</span>
),
},
{
accessorKey: 'filename',
header: ({ column }) => (
<DataTableColumnHeader
title="文件名"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
{getFileIcon(row.original.mimeType)}
<span className="max-w-[200px] truncate" title={row.original.filename}>
{row.original.filename}
</span>
</div>
),
},
{
accessorKey: 'mimeType',
header: '类型',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">{row.original.mimeType}</span>
),
},
{
accessorKey: 'size',
header: ({ column }) => (
<DataTableColumnHeader
title="大小"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) => formatFileSize(row.original.size),
},
{
accessorKey: 'purpose',
header: '用途',
cell: ({ row }) => (
<Badge variant={PURPOSE_VARIANTS[row.original.purpose] || 'outline'}>
{PURPOSE_LABELS[row.original.purpose] || row.original.purpose}
</Badge>
),
},
{
accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeader
title="上传时间"
sortKey={column.id}
sorting={sorting}
onSortingChange={handleSortingChange}
/>
),
cell: ({ row }) => formatDate(new Date(row.original.createdAt), 'YYYY-MM-DD HH:mm'),
},
{
id: 'actions',
header: '操作',
cell: ({ row }) => (
<FileActions file={row.original} onDelete={handleDelete} onPreview={handlePreview} />
),
},
];
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{filesData?.total ?? 0}</span>
{statsData && (
<>
{' '}
<span className="font-medium text-foreground">{statsData.totalSizeFormatted}</span>
</>
)}
</div>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
</Button>
</div>
{/* 数据表格 */}
<DataTable
columns={columns}
data={filesData?.items ?? []}
pagination={pagination}
paginationInfo={
filesData ? { total: filesData.total, totalPages: filesData.totalPages } : undefined
}
onPaginationChange={handlePaginationChange}
manualPagination
sorting={sorting}
onSortingChange={handleSortingChange}
manualSorting
searchConfig={fileSearchConfig}
searchParams={searchParams}
onSearchChange={handleSearchChange}
isLoading={isLoading}
emptyMessage="暂无文件"
/>
{/* 删除确认弹窗 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getFileUrl } from '@/services/file.service'; import { fileService } from '@/services/file.service';
/** /**
* 获取文件临时访问 URL 的 Hook * 获取文件临时访问 URL 的 Hook
@@ -8,9 +8,9 @@ import { getFileUrl } from '@/services/file.service';
export function useFileUrl(fileId: string | null | undefined) { export function useFileUrl(fileId: string | null | undefined) {
return useQuery({ return useQuery({
queryKey: ['file-url', fileId], queryKey: ['file-url', fileId],
queryFn: () => getFileUrl(fileId!), queryFn: () => fileService.getFileUrl(fileId!),
enabled: !!fileId, enabled: !!fileId,
// URL 有效期 5 钟,提前 1 分钟刷新 // URL 有效期 5 <EFBFBD><EFBFBD><EFBFBD>钟,提前 1 分钟刷新
staleTime: 4 * 60 * 1000, staleTime: 4 * 60 * 1000,
refetchInterval: 4 * 60 * 1000, refetchInterval: 4 * 60 * 1000,
}); });

View File

@@ -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() });
},
});
}

View File

@@ -1,5 +1,7 @@
import type { PaginatedResponse } from '@seclusion/shared';
import { API_ENDPOINTS } from '@/config/constants'; 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'; export type FilePurpose = 'avatar' | 'attachment';
@@ -20,51 +22,92 @@ export interface PresignedUrlResponse {
expiresIn: number; expiresIn: number;
} }
/** /** 文件统计响应 */
* 上传文件 export interface FileStatsResponse {
*/ totalFiles: number;
export async function uploadFile( totalSize: number;
file: File, totalSizeFormatted: string;
purpose: FilePurpose, byPurpose: Record<string, number>;
): Promise<FileResponse> { byMimeType: Record<string, number>;
const formData = new FormData(); }
formData.append('file', file);
formData.append('purpose', purpose);
const config: CustomRequestConfig = { /** 文件列表查询参数 */
headers: { export interface GetFilesParams {
'Content-Type': 'multipart/form-data', page?: number;
}, pageSize?: number;
skipEncryption: true, sortBy?: string;
}; sortOrder?: 'asc' | 'desc';
purpose?: FilePurpose;
const response = await httpClient.post<FileResponse>( uploaderId?: string;
`${API_ENDPOINTS.FILES}/upload`, mimeType?: string;
formData,
config,
);
return response.data;
} }
/** /**
* 获取文件临时访问 URL * 文件服务
*/ */
export async function getFileUrl( export const fileService = {
fileId: string, /**
expiresIn?: number, * 获取文件列表(分页)
): Promise<PresignedUrlResponse> { */
const params = expiresIn ? { expiresIn } : {}; getFiles: (params: GetFilesParams = {}): Promise<PaginatedResponse<FileResponse>> => {
const response = await httpClient.get<PresignedUrlResponse>( return http.get(API_ENDPOINTS.FILES, { params });
`${API_ENDPOINTS.FILES}/${fileId}/url`, },
{ params },
);
return response.data;
}
/** /**
* 删除文件 * 获取文件统计信息
*/ */
export async function deleteFile(fileId: string): Promise<void> { getStats: (): Promise<FileStatsResponse> => {
await httpClient.delete(`${API_ENDPOINTS.FILES}/${fileId}`); return http.get(`${API_ENDPOINTS.FILES}/stats`);
} },
/**
* 获取单个文件信息
*/
getFile: (id: string): Promise<FileResponse> => {
return http.get(`${API_ENDPOINTS.FILES}/${id}`);
},
/**
* 上传文件
*/
upload: async (file: File, purpose: FilePurpose): Promise<FileResponse> => {
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<FileResponse>(
`${API_ENDPOINTS.FILES}/upload`,
formData,
config,
);
return response.data;
},
/**
* 获取文件临时访问 URL
*/
getFileUrl: (fileId: string, expiresIn?: number): Promise<PresignedUrlResponse> => {
const params = expiresIn ? { expiresIn } : {};
return http.get(`${API_ENDPOINTS.FILES}/${fileId}/url`, { params });
},
/**
* 删除文件
*/
deleteFile: (fileId: string): Promise<void> => {
return http.delete(`${API_ENDPOINTS.FILES}/${fileId}`);
},
};
// 兼容旧的导出方式
export const uploadFile = fileService.upload;
export const getFileUrl = fileService.getFileUrl;
export const deleteFile = fileService.deleteFile;