mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
fix(frontend): keep browser API on same-origin rewrites in dev
Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local next dev so axios hits /api/* and Next rewrites proxy to the backend.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
@@ -81,6 +82,7 @@ function formatDate(dateStr: string) {
|
||||
}
|
||||
|
||||
export function FileList() {
|
||||
const t = useTranslations('admin.files');
|
||||
const queryClient = useQueryClient();
|
||||
const [keyword, setKeyword] = React.useState('');
|
||||
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
|
||||
@@ -157,10 +159,10 @@ export function FileList() {
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
|
||||
toast.success('文件已删除');
|
||||
toast.success(t('list.deleteSuccess'));
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message || '删除失败'),
|
||||
onError: (err: Error) => toast.error(err.message || t('list.deleteFailed')),
|
||||
});
|
||||
|
||||
// 批量 ZIP 下载
|
||||
@@ -173,9 +175,9 @@ export function FileList() {
|
||||
a.download = 'batch_download.zip';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('批量下载已开始');
|
||||
toast.success(t('list.batchDownloadSuccess'));
|
||||
},
|
||||
onError: () => toast.error('批量下载失败'),
|
||||
onError: () => toast.error(t('list.batchDownloadFailed')),
|
||||
});
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
@@ -209,7 +211,7 @@ export function FileList() {
|
||||
<div className='relative flex-1 max-w-sm'>
|
||||
<Search className='absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
placeholder='搜索文件名...'
|
||||
placeholder={t('list.searchPlaceholder')}
|
||||
className='pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0'
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
@@ -232,7 +234,9 @@ export function FileList() {
|
||||
selectedIds.size === files.length ? clearSelection : selectAll
|
||||
}
|
||||
>
|
||||
{selectedIds.size === files.length ? '取消全选' : '全选本页'}
|
||||
{selectedIds.size === files.length
|
||||
? t('list.deselectAll')
|
||||
: t('list.selectAll')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -247,7 +251,7 @@ export function FileList() {
|
||||
className='flex items-center gap-2'
|
||||
>
|
||||
<Badge variant='secondary' className='text-xs px-2.5'>
|
||||
已选 {selectedIds.size} 个
|
||||
{t('list.selectedCount', { count: selectedIds.size })}
|
||||
</Badge>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -261,7 +265,7 @@ export function FileList() {
|
||||
) : (
|
||||
<FileArchive className='size-3.5 mr-1' />
|
||||
)}
|
||||
打包下载
|
||||
{t('list.batchDownload')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -278,7 +282,7 @@ export function FileList() {
|
||||
|
||||
{total > 0 && (
|
||||
<span className='text-xs text-muted-foreground shrink-0'>
|
||||
共 {total} 个文件
|
||||
{t('list.totalFiles', { count: total })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -292,7 +296,7 @@ export function FileList() {
|
||||
<div className='flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground'>
|
||||
<Upload className='size-12 text-muted-foreground/30' />
|
||||
<p className='text-sm'>
|
||||
{debouncedKeyword ? '没有匹配的文件' : '您还没有上传任何文件'}
|
||||
{debouncedKeyword ? t('list.noMatch') : t('list.noFiles')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -309,18 +313,28 @@ export function FileList() {
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className='w-[80px] py-3'>预览</TableHead>
|
||||
<TableHead className='w-[180px] py-3'>ID</TableHead>
|
||||
<TableHead className='py-3'>文件名</TableHead>
|
||||
<TableHead className='max-w-[200px] truncate py-3'>
|
||||
路径
|
||||
<TableHead className='w-[80px] py-3'>
|
||||
{t('list.colPreview')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[180px] py-3'>ID</TableHead>
|
||||
<TableHead className='py-3'>{t('list.colName')}</TableHead>
|
||||
<TableHead className='max-w-[200px] truncate py-3'>
|
||||
{t('list.colPath')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] py-3'>
|
||||
{t('list.colType')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[125px] py-3'>
|
||||
{t('list.colMime')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] py-3'>
|
||||
{t('list.colSize')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[150px] py-3'>
|
||||
{t('list.colUploadedAt')}
|
||||
</TableHead>
|
||||
<TableHead className='w-[100px] py-3'>业务类别</TableHead>
|
||||
<TableHead className='w-[125px] py-3'>MIME类型</TableHead>
|
||||
<TableHead className='w-[100px] py-3'>大小</TableHead>
|
||||
<TableHead className='w-[150px] py-3'>上传时间</TableHead>
|
||||
<TableHead className='w-[120px] text-right pr-4 py-3'>
|
||||
操作
|
||||
{t('list.colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -396,7 +410,7 @@ export function FileList() {
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
title='查看详情'
|
||||
title={t('list.viewDetail')}
|
||||
onClick={() => setDetailTarget(file)}
|
||||
>
|
||||
<Eye className='size-3.5' />
|
||||
@@ -405,7 +419,7 @@ export function FileList() {
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='h-7 w-7 rounded-md hover:bg-muted'
|
||||
title='下载'
|
||||
title={t('list.download')}
|
||||
onClick={() => handleDownload(file)}
|
||||
>
|
||||
<Download className='size-3.5' />
|
||||
@@ -414,7 +428,7 @@ export function FileList() {
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
className='h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
|
||||
title='删除'
|
||||
title={t('list.delete')}
|
||||
onClick={() => setDeleteTarget(file)}
|
||||
>
|
||||
<Trash2 className='size-3.5' />
|
||||
@@ -439,7 +453,7 @@ export function FileList() {
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
上一页
|
||||
{t('list.prevPage')}
|
||||
</Button>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
{page} / {totalPages}
|
||||
@@ -451,7 +465,7 @@ export function FileList() {
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
{t('list.nextPage')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -463,10 +477,8 @@ export function FileList() {
|
||||
>
|
||||
<SheetContent className='w-full sm:max-w-md overflow-y-auto p-6'>
|
||||
<SheetHeader className='border-b pb-4 px-0'>
|
||||
<SheetTitle>文件详情</SheetTitle>
|
||||
<SheetDescription>
|
||||
查看文件的完整元数据与配置属性。
|
||||
</SheetDescription>
|
||||
<SheetTitle>{t('list.detailTitle')}</SheetTitle>
|
||||
<SheetDescription>{t('list.detailDesc')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{detailTarget && (
|
||||
@@ -495,7 +507,7 @@ export function FileList() {
|
||||
<div className='space-y-4 text-xs'>
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
文件 ID
|
||||
{t('list.detailId')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
|
||||
{detailTarget.id}
|
||||
@@ -504,7 +516,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
文件名
|
||||
{t('list.detailName')}
|
||||
</span>
|
||||
<span className='col-span-2 break-all font-medium text-foreground/90'>
|
||||
{detailTarget.file_name}
|
||||
@@ -513,7 +525,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
存储路径
|
||||
{t('list.detailPath')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
|
||||
{detailTarget.file_path}
|
||||
@@ -522,17 +534,17 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
大小
|
||||
{t('list.detailSize')}
|
||||
</span>
|
||||
<span className='col-span-2 text-foreground/90'>
|
||||
{formatFileSize(detailTarget.file_size)} (
|
||||
{detailTarget.file_size.toLocaleString()} 字节)
|
||||
{detailTarget.file_size.toLocaleString()} {t('list.bytes')})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
MIME 类型
|
||||
{t('list.detailMime')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono text-foreground/90'>
|
||||
{detailTarget.mime_type}
|
||||
@@ -541,7 +553,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
文件后缀
|
||||
{t('list.detailExtension')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono text-foreground/90'>
|
||||
{detailTarget.extension}
|
||||
@@ -559,7 +571,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
业务类型
|
||||
{t('list.detailBizType')}
|
||||
</span>
|
||||
<span className='col-span-2'>
|
||||
<Badge
|
||||
@@ -573,7 +585,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
存储驱动
|
||||
{t('list.detailDriver')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono text-foreground/90'>
|
||||
{storageDriverQuery.data ?? 'local'}
|
||||
@@ -582,7 +594,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
上传者 ID
|
||||
{t('list.detailUploaderId')}
|
||||
</span>
|
||||
<span className='col-span-2 font-mono text-foreground/90'>
|
||||
{detailTarget.user_id}
|
||||
@@ -591,7 +603,7 @@ export function FileList() {
|
||||
|
||||
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
|
||||
<span className='text-muted-foreground font-medium'>
|
||||
上传时间
|
||||
{t('list.detailUploadedAt')}
|
||||
</span>
|
||||
<span className='col-span-2 text-foreground/90'>
|
||||
{formatDate(detailTarget.created_at)}
|
||||
@@ -602,7 +614,7 @@ export function FileList() {
|
||||
Object.keys(detailTarget.metadata).length > 0 && (
|
||||
<div className='space-y-1.5 pt-2'>
|
||||
<span className='text-muted-foreground font-medium block'>
|
||||
额外元数据
|
||||
{t('list.detailMetadata')}
|
||||
</span>
|
||||
<pre className='p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto'>
|
||||
{JSON.stringify(detailTarget.metadata, null, 2)}
|
||||
@@ -622,18 +634,14 @@ export function FileList() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('list.deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除文件{' '}
|
||||
<span className='font-semibold text-foreground'>
|
||||
「{deleteTarget?.file_name}」
|
||||
</span>{' '}
|
||||
吗?此操作不可撤销。
|
||||
{t('list.deleteConfirm', { name: deleteTarget?.file_name || '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{t('list.cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() =>
|
||||
@@ -645,7 +653,7 @@ export function FileList() {
|
||||
{deleteMutation.isPending && (
|
||||
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
|
||||
)}
|
||||
确认删除
|
||||
{t('list.confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Database,
|
||||
@@ -51,40 +52,41 @@ const categoryMap: Record<string, string> = {
|
||||
|
||||
const statsChartConfig = {
|
||||
images: {
|
||||
label: '图片',
|
||||
label: 'images',
|
||||
color: 'hsl(var(--chart-1))',
|
||||
},
|
||||
videos: {
|
||||
label: '视频',
|
||||
label: 'videos',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
audio: {
|
||||
label: '音频',
|
||||
label: 'audio',
|
||||
color: 'hsl(var(--chart-3))',
|
||||
},
|
||||
documents: {
|
||||
label: '文档',
|
||||
label: 'documents',
|
||||
color: 'hsl(var(--chart-4))',
|
||||
},
|
||||
archives: {
|
||||
label: '压缩包',
|
||||
label: 'archives',
|
||||
color: 'hsl(var(--chart-5))',
|
||||
},
|
||||
others: {
|
||||
label: '其他',
|
||||
label: 'others',
|
||||
color: 'hsl(var(--chart-6))',
|
||||
},
|
||||
count: {
|
||||
label: '新增文件数',
|
||||
label: 'count',
|
||||
color: 'hsl(var(--primary))',
|
||||
},
|
||||
size: {
|
||||
label: '新增大小',
|
||||
label: 'size',
|
||||
color: 'hsl(var(--chart-2))',
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function FileStats() {
|
||||
const t = useTranslations('admin.files');
|
||||
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
|
||||
'count',
|
||||
);
|
||||
@@ -182,9 +184,9 @@ export function FileStats() {
|
||||
<div className='flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4'>
|
||||
<Upload className='size-14 text-muted-foreground/25' />
|
||||
<div className='text-center space-y-1'>
|
||||
<p className='font-medium'>暂无上传数据</p>
|
||||
<p className='font-medium'>{t('stats.noData')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
上传文件后即可在此查看容量与文件分布分析。
|
||||
{t('stats.noDataDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +200,7 @@ export function FileStats() {
|
||||
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
|
||||
总文件数
|
||||
{t('stats.totalFiles')}
|
||||
</CardTitle>
|
||||
<Files className='size-4 text-sky-500' />
|
||||
</CardHeader>
|
||||
@@ -206,11 +208,11 @@ export function FileStats() {
|
||||
<div className='text-2xl font-bold font-mono text-sky-500'>
|
||||
{stats.total_count.toLocaleString()}{' '}
|
||||
<span className='text-xs font-normal text-muted-foreground'>
|
||||
个
|
||||
{t('stats.countUnit')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
您上传的可用文件数量汇总
|
||||
{t('stats.totalFilesDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -218,7 +220,7 @@ export function FileStats() {
|
||||
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
|
||||
占用存储容量
|
||||
{t('stats.totalSize')}
|
||||
</CardTitle>
|
||||
<Database className='size-4 text-emerald-500' />
|
||||
</CardHeader>
|
||||
@@ -227,7 +229,7 @@ export function FileStats() {
|
||||
{formatFileSize(stats.total_size)}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
文件实际在磁盘/S3中占用的总空间
|
||||
{t('stats.totalSizeDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -235,7 +237,7 @@ export function FileStats() {
|
||||
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
|
||||
近 7 天新增文件
|
||||
{t('stats.weeklyNewFiles')}
|
||||
</CardTitle>
|
||||
<TrendingUp className='size-4 text-indigo-500' />
|
||||
</CardHeader>
|
||||
@@ -243,11 +245,11 @@ export function FileStats() {
|
||||
<div className='text-2xl font-bold font-mono text-indigo-500'>
|
||||
+{trendSummary.count.toLocaleString()}{' '}
|
||||
<span className='text-xs font-normal text-muted-foreground'>
|
||||
个
|
||||
{t('stats.countUnit')}
|
||||
</span>
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
最近 7 天上传成功的文件总数
|
||||
{t('stats.weeklyNewFilesDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -255,7 +257,7 @@ export function FileStats() {
|
||||
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
|
||||
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
|
||||
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
|
||||
近 7 天新增大小
|
||||
{t('stats.weeklyNewSize')}
|
||||
</CardTitle>
|
||||
<HardDrive className='size-4 text-amber-500' />
|
||||
</CardHeader>
|
||||
@@ -264,7 +266,7 @@ export function FileStats() {
|
||||
+{formatFileSize(trendSummary.size)}
|
||||
</div>
|
||||
<p className='text-[10px] text-muted-foreground mt-1'>
|
||||
最近 7 天上传所消耗的存储带宽
|
||||
{t('stats.weeklyNewSizeDesc')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -275,10 +277,10 @@ export function FileStats() {
|
||||
<CardHeader className='flex flex-row items-center justify-between'>
|
||||
<div>
|
||||
<CardTitle className='text-sm font-bold'>
|
||||
最近 7 天新增趋势
|
||||
{t('stats.trendTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
按日统计近一周期内的上传波动
|
||||
{t('stats.trendDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className='flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30'>
|
||||
@@ -288,7 +290,7 @@ export function FileStats() {
|
||||
className='h-7 text-xs px-2.5 rounded-md'
|
||||
onClick={() => setTrendMetric('count')}
|
||||
>
|
||||
新增数量 (个)
|
||||
{t('stats.trendCount')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
@@ -296,7 +298,7 @@ export function FileStats() {
|
||||
className='h-7 text-xs px-2.5 rounded-md'
|
||||
onClick={() => setTrendMetric('size')}
|
||||
>
|
||||
新增大小 (MB)
|
||||
{t('stats.trendSize')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -361,7 +363,9 @@ export function FileStats() {
|
||||
hideLabel
|
||||
formatter={(value) => {
|
||||
const label =
|
||||
trendMetric === 'count' ? '新增文件数' : '新增大小';
|
||||
trendMetric === 'count'
|
||||
? t('stats.chartLabelCount')
|
||||
: t('stats.chartLabelSize');
|
||||
const formattedValue =
|
||||
trendMetric === 'size'
|
||||
? formatFileSize(Number(value))
|
||||
@@ -419,16 +423,16 @@ export function FileStats() {
|
||||
<Card className='bg-card/20 border-border/40 flex flex-col'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-bold'>
|
||||
文件格式数量分布
|
||||
{t('stats.countDistTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
各种格式类型的文件个数占比
|
||||
{t('stats.countDistDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex-1 flex flex-col justify-between gap-4'>
|
||||
{categoryCountData.length === 0 ? (
|
||||
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
|
||||
暂无分类数据
|
||||
{t('stats.noCategoryData')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='h-[240px] w-full'>
|
||||
@@ -494,15 +498,10 @@ export function FileStats() {
|
||||
<div className='bg-sky-500/5 border border-sky-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-sky-600 dark:text-sky-400'>
|
||||
<Info className='size-4 shrink-0 mt-0.5' />
|
||||
<p className='leading-normal'>
|
||||
文件类型中数量最多的是{' '}
|
||||
<strong className='font-bold'>
|
||||
「{maxStats.maxCountName}」
|
||||
</strong>
|
||||
,累计上传了{' '}
|
||||
<strong className='font-mono font-bold'>
|
||||
{maxStats.maxCount}
|
||||
</strong>{' '}
|
||||
个文件。
|
||||
{t('stats.maxCountHint', {
|
||||
name: maxStats.maxCountName,
|
||||
count: maxStats.maxCount,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -512,16 +511,16 @@ export function FileStats() {
|
||||
<Card className='bg-card/20 border-border/40 flex flex-col'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-bold'>
|
||||
文件占用容量分布
|
||||
{t('stats.sizeDistTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
各种格式类型的文件大小占比
|
||||
{t('stats.sizeDistDesc')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex-1 flex flex-col justify-between gap-4'>
|
||||
{categorySizeData.length === 0 ? (
|
||||
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
|
||||
暂无容量数据
|
||||
{t('stats.noSizeData')}
|
||||
</div>
|
||||
) : (
|
||||
<div className='h-[240px] w-full'>
|
||||
@@ -587,15 +586,10 @@ export function FileStats() {
|
||||
<div className='bg-emerald-500/5 border border-emerald-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-emerald-600 dark:text-emerald-400'>
|
||||
<Info className='size-4 shrink-0 mt-0.5' />
|
||||
<p className='leading-normal'>
|
||||
文件类型中占用存储最大的是{' '}
|
||||
<strong className='font-bold'>
|
||||
「{maxStats.maxSizeName}」
|
||||
</strong>
|
||||
,共消耗了{' '}
|
||||
<strong className='font-mono font-bold'>
|
||||
{formatFileSize(maxStats.maxSize)}
|
||||
</strong>{' '}
|
||||
存储容量。
|
||||
{t('stats.maxSizeHint', {
|
||||
name: maxStats.maxSizeName,
|
||||
size: formatFileSize(maxStats.maxSize),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Database, Loader2, Play, Save } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -44,13 +45,13 @@ import type {
|
||||
const storageConfigKey = 'storage_config';
|
||||
const storageMigrationTaskType = 'storage_migration';
|
||||
|
||||
const driverLabels: Record<StorageDriver, string> = {
|
||||
local: '本地文件系统',
|
||||
s3: 'AWS S3',
|
||||
r2: 'Cloudflare R2',
|
||||
minio: 'MinIO',
|
||||
oss: '阿里云 OSS',
|
||||
webdav: 'WebDAV',
|
||||
const driverLabelKeys: Record<StorageDriver, string> = {
|
||||
local: 'driverLocal',
|
||||
s3: 'driverS3',
|
||||
r2: 'driverR2',
|
||||
minio: 'driverMinio',
|
||||
oss: 'driverOss',
|
||||
webdav: 'driverWebdav',
|
||||
};
|
||||
|
||||
const emptyObjectConfig: ObjectStorageConfig = {
|
||||
@@ -112,8 +113,8 @@ function parseMigrationPayload(payload: string): StorageConfig | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function driverLabel(driver?: StorageDriver): string {
|
||||
return driver ? driverLabels[driver] : '未知';
|
||||
function driverLabelKey(driver?: StorageDriver): string {
|
||||
return driver ? driverLabelKeys[driver] : 'driverUnknown';
|
||||
}
|
||||
|
||||
function latestMigration(
|
||||
@@ -136,6 +137,7 @@ function latestMigration(
|
||||
}
|
||||
|
||||
export function StorageConfigTab() {
|
||||
const t = useTranslations('admin.files');
|
||||
const queryClient = useQueryClient();
|
||||
const [config, setConfig] = React.useState<StorageConfig | null>(null);
|
||||
const query = useQuery({
|
||||
@@ -186,13 +188,13 @@ export function StorageConfigTab() {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('存储配置已保存');
|
||||
toast.success(t('storage.saveSuccess'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '保存存储配置失败');
|
||||
toast.error(error.message || t('storage.saveFailed'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
@@ -208,13 +210,13 @@ export function StorageConfigTab() {
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('存储迁移任务已下发');
|
||||
toast.success(t('storage.migrateSuccess'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || '下发存储迁移任务失败');
|
||||
toast.error(error.message || t('storage.migrateFailed'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
@@ -224,12 +226,13 @@ export function StorageConfigTab() {
|
||||
mutationFn: (executionID: string) =>
|
||||
services.adminTask.retryTaskExecution(executionID),
|
||||
onSuccess: () => {
|
||||
toast.success('存储迁移任务已重新下发');
|
||||
toast.success(t('storage.retrySuccess'));
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'storage-config'],
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'),
|
||||
onError: (error: Error) =>
|
||||
toast.error(error.message || t('storage.retryFailed')),
|
||||
});
|
||||
|
||||
if (query.isPending || !config) {
|
||||
@@ -269,7 +272,7 @@ export function StorageConfigTab() {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2'>
|
||||
存储维护模式
|
||||
{t('storage.maintenanceMode')}
|
||||
<Badge
|
||||
variant={
|
||||
migration.state === 'failed' ? 'destructive' : 'secondary'
|
||||
@@ -279,9 +282,10 @@ export function StorageConfigTab() {
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{driverLabel(migration.source_driver)} →{' '}
|
||||
{driverLabel(migration.target_driver)}
|
||||
。迁移期间文件只允许读取,禁止上传、删除和清理。
|
||||
{t('storage.maintenanceDesc', {
|
||||
source: t(`storage.${driverLabelKey(migration.source_driver)}`),
|
||||
target: t(`storage.${driverLabelKey(migration.target_driver)}`),
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-3'>
|
||||
@@ -289,7 +293,9 @@ export function StorageConfigTab() {
|
||||
value={migration.state === 'succeeded' ? 100 : undefined}
|
||||
/>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'}
|
||||
{t('storage.migrationProgress', {
|
||||
taskId: migration.task_id || t('storage.notDispatched'),
|
||||
})}
|
||||
</p>
|
||||
{migration.error && (
|
||||
<p className='text-sm text-destructive'>{migration.error}</p>
|
||||
@@ -310,7 +316,7 @@ export function StorageConfigTab() {
|
||||
) : (
|
||||
<Play data-icon='inline-start' />
|
||||
)}
|
||||
重试迁移
|
||||
{t('storage.retryMigration')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -324,10 +330,10 @@ export function StorageConfigTab() {
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
文件存储
|
||||
{t('storage.fileStorage')}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
|
||||
{t('storage.fileStorageDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -337,7 +343,7 @@ export function StorageConfigTab() {
|
||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
|
||||
<Field className='flex flex-col gap-1.5 md:col-span-2'>
|
||||
<FieldLabel className='text-xs font-semibold'>
|
||||
存储类型
|
||||
{t('storage.storageType')}
|
||||
</FieldLabel>
|
||||
<Select
|
||||
value={config.driver}
|
||||
@@ -351,10 +357,10 @@ export function StorageConfigTab() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{(Object.keys(driverLabels) as StorageDriver[]).map(
|
||||
{(Object.keys(driverLabelKeys) as StorageDriver[]).map(
|
||||
(driver) => (
|
||||
<SelectItem key={driver} value={driver}>
|
||||
{driverLabels[driver]}
|
||||
{t(`storage.${driverLabelKeys[driver]}`)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
@@ -366,7 +372,7 @@ export function StorageConfigTab() {
|
||||
{config.driver === 'local' && (
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label='根目录'
|
||||
label={t('storage.rootDir')}
|
||||
value={config.local.root}
|
||||
placeholder='.'
|
||||
onChange={(root) =>
|
||||
@@ -396,7 +402,7 @@ export function StorageConfigTab() {
|
||||
<>
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label='服务地址'
|
||||
label={t('storage.endpoint')}
|
||||
value={config.webdav.endpoint}
|
||||
placeholder='https://dav.example.com'
|
||||
onChange={(endpoint) =>
|
||||
@@ -408,7 +414,7 @@ export function StorageConfigTab() {
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label='用户名'
|
||||
label={t('storage.username')}
|
||||
value={config.webdav.username}
|
||||
onChange={(username) =>
|
||||
setConfig({
|
||||
@@ -418,7 +424,7 @@ export function StorageConfigTab() {
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label='密码'
|
||||
label={t('storage.password')}
|
||||
type='password'
|
||||
value={config.webdav.password}
|
||||
onChange={(password) =>
|
||||
@@ -430,7 +436,7 @@ export function StorageConfigTab() {
|
||||
/>
|
||||
<div className='md:col-span-2'>
|
||||
<TextField
|
||||
label='基础路径'
|
||||
label={t('storage.basePath')}
|
||||
value={config.webdav.base_path}
|
||||
placeholder='wavelet'
|
||||
onChange={(base_path) =>
|
||||
@@ -449,8 +455,7 @@ export function StorageConfigTab() {
|
||||
<CardFooter className='justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6'>
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<span className='text-xs text-amber-500 mr-auto text-left max-w-md'>
|
||||
⚠️
|
||||
您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
|
||||
{t('storage.switchWarning')}
|
||||
</span>
|
||||
)}
|
||||
<div className='flex gap-2'>
|
||||
@@ -465,7 +470,7 @@ export function StorageConfigTab() {
|
||||
) : (
|
||||
<Save data-icon='inline-start' />
|
||||
)}
|
||||
保存配置
|
||||
{t('storage.saveConfig')}
|
||||
</Button>
|
||||
{config.driver !== query.data?.config.driver && (
|
||||
<Button
|
||||
@@ -477,7 +482,7 @@ export function StorageConfigTab() {
|
||||
) : (
|
||||
<Play data-icon='inline-start' />
|
||||
)}
|
||||
开始迁移
|
||||
{t('storage.startMigration')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -496,6 +501,7 @@ function ObjectFields({
|
||||
value: ObjectStorageConfig;
|
||||
onChange: (patch: Partial<ObjectStorageConfig>) => void;
|
||||
}) {
|
||||
const t = useTranslations('admin.files');
|
||||
return (
|
||||
<>
|
||||
{driver === 'r2' && (
|
||||
@@ -539,13 +545,13 @@ function ObjectFields({
|
||||
onChange={(secret_access_key) => onChange({ secret_access_key })}
|
||||
/>
|
||||
<TextField
|
||||
label='对象前缀'
|
||||
label={t('storage.keyPrefix')}
|
||||
value={value.key_prefix}
|
||||
placeholder='uploads'
|
||||
onChange={(key_prefix) => onChange({ key_prefix })}
|
||||
/>
|
||||
<TextField
|
||||
label='CDN 地址'
|
||||
label={t('storage.cdnUrl')}
|
||||
value={value.cdn_url}
|
||||
placeholder='https://cdn.example.com'
|
||||
onChange={(cdn_url) => onChange({ cdn_url })}
|
||||
@@ -561,7 +567,7 @@ function ObjectFields({
|
||||
Path Style
|
||||
</FieldLabel>
|
||||
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
|
||||
MinIO 等自托管 S3 通常需要开启。
|
||||
{t('storage.pathStyleDesc')}
|
||||
</FieldDescription>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { FileStats } from './components/file-stats';
|
||||
@@ -10,6 +11,7 @@ import { FileList } from './components/file-list';
|
||||
import { StorageConfigTab } from './components/storage-config-tab';
|
||||
|
||||
export default function FilesPage() {
|
||||
const t = useTranslations('admin.files');
|
||||
const [activeTab, setActiveTab] = React.useState('stats');
|
||||
|
||||
return (
|
||||
@@ -23,7 +25,9 @@ export default function FilesPage() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<FolderOpen className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>存储管理</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('pageTitle')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,16 +37,16 @@ export default function FilesPage() {
|
||||
value='stats'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
文件存储信息
|
||||
{t('tabStats')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='list' className='px-0 pb-2 text-xs font-semibold'>
|
||||
文件列表
|
||||
{t('tabList')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='storage'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
存储配置
|
||||
{t('tabStorage')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user