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:
38
apps/web/src/app/(dashboard)/files/page.tsx
Normal file
38
apps/web/src/app/(dashboard)/files/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
apps/web/src/components/files/FileStats.tsx
Normal file
78
apps/web/src/components/files/FileStats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
apps/web/src/components/files/FilesTable.tsx
Normal file
354
apps/web/src/components/files/FilesTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
96
apps/web/src/hooks/useFiles.ts
Normal file
96
apps/web/src/hooks/useFiles.ts
Normal 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() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user