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 { 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 <EFBFBD><EFBFBD><EFBFBD>钟,提前 1 分钟刷新
|
||||
staleTime: 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 { 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<FileResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('purpose', purpose);
|
||||
/** 文件统计响应 */
|
||||
export interface FileStatsResponse {
|
||||
totalFiles: number;
|
||||
totalSize: number;
|
||||
totalSizeFormatted: string;
|
||||
byPurpose: Record<string, number>;
|
||||
byMimeType: Record<string, number>;
|
||||
}
|
||||
|
||||
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;
|
||||
/** 文件列表查询参数 */
|
||||
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<PresignedUrlResponse> {
|
||||
const params = expiresIn ? { expiresIn } : {};
|
||||
const response = await httpClient.get<PresignedUrlResponse>(
|
||||
`${API_ENDPOINTS.FILES}/${fileId}/url`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
export const fileService = {
|
||||
/**
|
||||
* 获取文件列表(分页)
|
||||
*/
|
||||
getFiles: (params: GetFilesParams = {}): Promise<PaginatedResponse<FileResponse>> => {
|
||||
return http.get(API_ENDPOINTS.FILES, { params });
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
export async function deleteFile(fileId: string): Promise<void> {
|
||||
await httpClient.delete(`${API_ENDPOINTS.FILES}/${fileId}`);
|
||||
}
|
||||
/**
|
||||
* 获取文件统计信息
|
||||
*/
|
||||
getStats: (): Promise<FileStatsResponse> => {
|
||||
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