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:
ryan
2026-07-24 16:37:24 +08:00
parent a4588b05e0
commit 9f15f42b47
75 changed files with 5971 additions and 2450 deletions
@@ -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