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
+11 -7
View File
@@ -1,17 +1,21 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api';
import {
getApiSections,
DOCS_LAST_UPDATED,
} from '@/components/common/docs/api';
export default async function ApiDocPage() {
const t = await getTranslations('docs');
export default function ApiDocPage() {
return (
<LegalPageLayout
title='API 接口文档'
title={t('api.title')}
lastUpdated={DOCS_LAST_UPDATED}
sections={apiSections}
sections={getApiSections(t)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
Wavelet 提供简单、强大的 API
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
接口,您可以轻松地与后端服务进行集成与二次开发。
{t('api.description')}
</p>
}
/>
+8 -6
View File
@@ -1,17 +1,19 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { howToUseSections } from '@/components/common/docs/how-to-use';
import { getHowToUseSections } from '@/components/common/docs/how-to-use';
import { DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function HowToUsePage() {
export default async function HowToUsePage() {
const t = await getTranslations('docs');
return (
<LegalPageLayout
title='平台使用指南'
title={t('howToUse.title')}
lastUpdated={DOCS_LAST_UPDATED}
sections={howToUseSections}
sections={getHowToUseSections(t)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
欢迎使用 Wavelet
脚手架系统。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
{t('howToUse.description')}
</p>
}
/>
@@ -1,18 +1,21 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { privacySections } from '@/components/common/docs/privacy';
import { getPrivacySections } from '@/components/common/docs/privacy';
import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms';
export default function PrivacyPolicyPage() {
export default async function PrivacyPolicyPage() {
const t = await getTranslations('docs');
return (
<LegalPageLayout
title='隐私政策 (Privacy)'
title={t('privacy.title')}
lastUpdated={TERMS_LAST_UPDATED}
sections={privacySections}
sections={getPrivacySections(t)}
description={
<span>
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
{t('privacy.description')}
<br className='hidden md:block' />
以及您享有的相关权利。
{t('privacy.descriptionRights')}
</span>
}
/>
@@ -1,16 +1,19 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
TERMS_LAST_UPDATED,
termsSections,
getTermsSections,
} from '@/components/common/docs/terms';
export default function TermsOfServicePage() {
export default async function TermsOfServicePage() {
const t = await getTranslations('docs');
return (
<LegalPageLayout
title='服务协议 (Terms)'
title={t('terms.title')}
lastUpdated={TERMS_LAST_UPDATED}
sections={termsSections}
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
sections={getTermsSections(t)}
description={<span>{t('terms.description')}</span>}
/>
);
}
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -52,6 +53,7 @@ interface CacheManagerProps {
}
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const t = useTranslations('admin.database.cache');
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
const [loadingCache, setLoadingCache] = useState<boolean>(true);
const [savingConfig, setSavingConfig] = useState<boolean>(false);
@@ -64,22 +66,25 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const [lruEnabled, setLruEnabled] = useState<boolean>(true);
// 获取磁盘缓存状态
const fetchCacheStatus = useCallback(async (isSilent = false) => {
if (!isSilent) setLoadingCache(true);
try {
const data = await services.adminCache.getCacheStatus();
setCacheStatus(data);
setMaxSizeMB(data.max_size_mb.toString());
setTtlMinutes(data.ttl_minutes.toString());
setLruEnabled(data.lru_enabled);
} catch (err) {
toast.error('获取磁盘缓存状态失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingCache(false);
}
}, []);
const fetchCacheStatus = useCallback(
async (isSilent = false) => {
if (!isSilent) setLoadingCache(true);
try {
const data = await services.adminCache.getCacheStatus();
setCacheStatus(data);
setMaxSizeMB(data.max_size_mb.toString());
setTtlMinutes(data.ttl_minutes.toString());
setLruEnabled(data.lru_enabled);
} catch (err) {
toast.error(t('fetchCacheStatusFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingCache(false);
}
},
[t],
);
// 保存配置
const handleSaveConfig = async (e: React.FormEvent) => {
@@ -87,14 +92,14 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const size = parseInt(maxSizeMB, 10);
const ttl = parseInt(ttlMinutes, 10);
if (isNaN(size) || size < 1) {
toast.error('保存失败', {
description: '最大容量限制必须是大于等于 1 的整数',
toast.error(t('saveFailed'), {
description: t('maxSizeMustBePositive'),
});
return;
}
if (isNaN(ttl) || ttl < 0) {
toast.error('保存失败', {
description: '默认过期时间必须是大于等于 0 的整数',
toast.error(t('saveFailed'), {
description: t('ttlMustBeNonNegative'),
});
return;
}
@@ -105,10 +110,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
ttl_minutes: ttl,
lru_enabled: lruEnabled,
});
toast.success('保存成功', { description: '磁盘缓存策略已热更新' });
toast.success(t('saveSuccess'), {
description: t('cachePolicyHotUpdated'),
});
await fetchCacheStatus(true);
} catch (err) {
toast.error('保存配置失败', {
toast.error(t('saveConfigFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
@@ -121,11 +128,11 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
setClearingCache(true);
try {
await services.adminCache.clearCache();
toast.success('清空成功', { description: '缓存数据已全部清除' });
toast.success(t('clearSuccess'), { description: t('cacheDataCleared') });
setShowClearConfirm(false);
await fetchCacheStatus(true);
} catch (err) {
toast.error('清空缓存失败', {
toast.error(t('clearCacheFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
@@ -149,10 +156,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<div className='space-y-0.5'>
<div className='flex items-center gap-2'>
<HardDrive className='size-4 text-primary animate-pulse' />
<CardTitle className='text-sm font-semibold'>缓存管理</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('cacheManagement')}
</CardTitle>
</div>
<CardDescription className='text-[11px]'>
管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略
{t('cacheManagementDesc')}
</CardDescription>
</div>
@@ -166,7 +175,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<RefreshCw
className={`size-3 ${loadingCache ? 'animate-spin' : ''}`}
/>
刷新状态
{t('refreshStatus')}
</Button>
</CardHeader>
<CardContent className='pt-4'>
@@ -181,13 +190,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* 左边:状态区 (2/5 cols) */}
<div className='lg:col-span-2 space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
运行状态
{t('runtimeStatus')}
</h4>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
{/* 已占空间 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1'>
已用空间
{t('usedSpace')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{formatBytes(cacheStatus.total_size)}
@@ -196,12 +205,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* Key数量 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1'>
缓存键数量
{t('cacheKeysCount')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{formatNumber(cacheStatus.keys_count)}{' '}
<span className='text-xs text-muted-foreground font-normal'>
个文件
{t('files')}
</span>
</p>
</div>
@@ -210,7 +219,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* 存储路径 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1.5'>
缓存基准目录
{t('cacheBaseDirectory')}
</p>
<code
className='text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate'
@@ -225,20 +234,20 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
className='h-8 text-xs font-medium w-full'
onClick={() => setShowClearConfirm(true)}
>
立即清空缓存
{t('clearCacheNow')}
</Button>
</div>
{/* 右边:配置区 (3/5 cols) */}
<div className='lg:col-span-3 border-t lg:border-t-0 lg:border-l border-border/40 pt-6 lg:pt-0 lg:pl-6 space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
策略配置
{t('policyConfig')}
</h4>
<form onSubmit={handleSaveConfig} className='space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label htmlFor='maxSizeMB' className='text-xs font-medium'>
最大容量限制 (MB)
{t('maxSizeLimit')}
</Label>
<Input
id='maxSizeMB'
@@ -251,13 +260,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
required
/>
<p className='text-[9px] text-muted-foreground'>
当总大小超出该值时,自动触发淘汰算法
{t('maxSizeHint')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='ttlMinutes' className='text-xs font-medium'>
生存时间限制 (分钟)
{t('ttlLimit')}
</Label>
<Input
id='ttlMinutes'
@@ -270,7 +279,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
required
/>
<p className='text-[9px] text-muted-foreground'>
缓存项的最长存活时间,超期后将失效被清理
{t('ttlHint')}
</p>
</div>
</div>
@@ -281,10 +290,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
htmlFor='lruEnabled'
className='text-xs font-semibold block cursor-pointer'
>
启用 LRU 淘汰机制
{t('enableLru')}
</Label>
<span className='text-[10px] text-muted-foreground block'>
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
{t('enableLruDesc')}
</span>
</div>
<Switch
@@ -300,7 +309,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
disabled={savingConfig}
className='h-8 text-xs px-4'
>
{savingConfig ? '正在保存...' : '保存设置'}
{savingConfig ? t('saving') : t('saveSettings')}
</Button>
</div>
</form>
@@ -309,7 +318,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
) : (
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
<HardDrive className='size-8 opacity-45 mb-2' />
<span className='text-xs'>未加载到缓存状态信息</span>
<span className='text-xs'>{t('noCacheStatus')}</span>
</div>
)}
</CardContent>
@@ -320,11 +329,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<DialogHeader>
<DialogTitle className='text-sm font-semibold flex items-center gap-2 text-destructive'>
<Trash2 className='size-4' />
确认清空所有缓存?
{t('confirmClearAllCache')}
</DialogTitle>
<DialogDescription className='text-xs text-muted-foreground pt-1'>
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为
0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
{t('confirmClearDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter className='flex gap-2 justify-end mt-4'>
@@ -335,7 +343,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
onClick={() => setShowClearConfirm(false)}
disabled={clearingCache}
>
取消
{t('cancel')}
</Button>
<Button
variant='destructive'
@@ -344,7 +352,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
onClick={handleClearCache}
disabled={clearingCache}
>
{clearingCache ? '清理中...' : '确认清空'}
{clearingCache ? t('clearing') : t('confirmClear')}
</Button>
</DialogFooter>
</DialogContent>
@@ -7,6 +7,7 @@ import CodeMirror from '@uiw/react-codemirror';
import { sql } from '@codemirror/lang-sql';
import { toast } from 'sonner';
import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
@@ -49,6 +50,7 @@ interface SQLConsoleProps {
}
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
const t = useTranslations('admin.database.sql');
// SQL 控制台状态
const [sqlQuery, setSqlQuery] = useState<string>('');
const [executingSQL, setExecutingSQL] = useState<boolean>(false);
@@ -97,10 +99,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
try {
const result = await DbManageService.executeSQL(sqlQuery);
setSqlResult(result);
toast.success('SQL 执行成功');
toast.success(t('sqlExecSuccess'));
} catch (err) {
setSqlError(err instanceof Error ? err.message : '未知执行错误');
toast.error('SQL 执行失败');
toast.error(t('sqlExecFailed'));
} finally {
setExecutingSQL(false);
}
@@ -134,7 +136,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<Terminal className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
SQL 查询终端
{t('sqlQueryTerminal')}
</h1>
</div>
</div>
@@ -153,34 +155,34 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
<div className='flex items-center gap-2'>
<Terminal className='size-4 text-primary' />
<span className='text-xs font-semibold'>SQL 编辑器</span>
<span className='text-xs font-semibold'>{t('sqlEditor')}</span>
</div>
<div className='flex items-center gap-3 flex-wrap'>
{/* 快速模板 */}
<div className='flex items-center gap-1.5'>
<span className='text-[11px] text-muted-foreground'>
快速模板:
{t('quickTemplates')}
</span>
<Select onValueChange={handlePresetSQLChange}>
<SelectTrigger className='h-7 w-[180px] text-[11px] bg-background'>
<SelectValue placeholder='选择预设 SQL' />
<SelectValue placeholder={t('selectPresetSQL')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='SELECT * FROM users LIMIT 10;'>
查询用户 (SELECT users)
{t('queryUsers')}
</SelectItem>
<SelectItem value='SELECT * FROM uploads LIMIT 10;'>
查询文件 (SELECT uploads)
{t('queryFiles')}
</SelectItem>
<SelectItem value='SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;'>
查询任务流水 (SELECT task_executions)
{t('queryTaskExecutions')}
</SelectItem>
<SelectItem value='SELECT sqlite_version();'>
SQLite 版本 (SQLite only)
{t('sqliteVersion')}
</SelectItem>
<SelectItem value='SELECT version();'>
PostgreSQL 版本 (Postgres only)
{t('postgresVersion')}
</SelectItem>
</SelectContent>
</Select>
@@ -204,7 +206,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
disabled={executingSQL || !sqlQuery.trim()}
>
<Play className='size-3' />
{executingSQL ? '运行中...' : '运行'}
{executingSQL ? t('running') : t('run')}
</Button>
</div>
</div>
@@ -245,10 +247,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex-1 min-h-[100px] flex flex-col overflow-hidden bg-muted/10'>
{/* 结果栏工具提示 */}
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono'>
<span className='font-semibold'>执行输出</span>
<span className='font-semibold'>{t('executionOutput')}</span>
{sqlResult && (
<span>
类型: {sqlResult.type.toUpperCase()} | 耗时:{' '}
{t('type')}: {sqlResult.type.toUpperCase()} | {t('duration')}:{' '}
{sqlResult.execution_time_ms} ms
</span>
)}
@@ -260,7 +262,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex flex-col items-center justify-center h-full py-10 space-y-2'>
<RefreshCw className='size-5 text-primary animate-spin' />
<span className='text-xs text-muted-foreground'>
正在在数据库执行查询,请稍候...
{t('executingQuery')}
</span>
</div>
)}
@@ -319,7 +321,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
colSpan={sqlResult.columns.length}
className='text-center py-10 text-muted-foreground'
>
查询结果为空
{t('queryResultEmpty')}
</TableCell>
</TableRow>
)}
@@ -331,11 +333,13 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
{sqlResult.type === 'exec' && (
<div className='bg-primary/5 border border-primary/10 text-primary-foreground font-mono text-xs p-6 rounded-lg text-center my-auto'>
<p className='text-foreground text-sm font-semibold'>
SQL 执行成功
{t('sqlExecSuccessTitle')}
</p>
<p className='text-muted-foreground mt-2'>
受影响行数: {sqlResult.affected_rows} 行,耗时{' '}
{sqlResult.execution_time_ms} 毫秒。
{t('affectedRows', {
rows: sqlResult.affected_rows,
ms: sqlResult.execution_time_ms,
})}
</p>
</div>
)}
@@ -345,9 +349,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
{!executingSQL && !sqlResult && !sqlError && (
<div className='flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60'>
<Terminal className='size-8 mb-2' />
<span className='text-xs'>
编辑器就绪,请在上方编写 SQL 并运行查看结果
</span>
<span className='text-xs'>{t('editorReady')}</span>
</div>
)}
</div>
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -67,6 +68,7 @@ export function TableBrowser({
loadingTables,
refreshTrigger,
}: TableBrowserProps) {
const t = useTranslations('admin.database.tableBrowser');
const [selectedTable, setSelectedTable] = useState<string>('');
const [tableData, setTableData] = useState<TableDataResponse | null>(null);
const [page, setPage] = useState<number>(1);
@@ -125,9 +127,11 @@ export function TableBrowser({
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
<CardHeader className='pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>数据表浏览器</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('tableBrowser')}
</CardTitle>
<CardDescription className='text-[11px]'>
浏览数据库中的物理数据表详情及内容
{t('tableBrowserDesc')}
</CardDescription>
</div>
@@ -137,7 +141,7 @@ export function TableBrowser({
) : (
<Select value={selectedTable} onValueChange={handleTableChange}>
<SelectTrigger className='h-8 w-[200px] text-xs bg-background border-border/40'>
<SelectValue placeholder='选择数据表' />
<SelectValue placeholder={t('selectTable')} />
</SelectTrigger>
<SelectContent className='max-h-[300px]'>
{tables.map((t) => (
@@ -202,7 +206,7 @@ export function TableBrowser({
colSpan={tableData.columns.length}
className='text-center py-10 text-muted-foreground'
>
表中无记录数据
{t('noRecordsInTable')}
</TableCell>
</TableRow>
)}
@@ -213,11 +217,11 @@ export function TableBrowser({
{/* 分页控制 */}
<div className='flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed'>
<div>
总行数:{' '}
{t('totalRows')}:{' '}
<span className='font-mono text-foreground font-semibold'>
{formatNumber(tableData.total)}
</span>{' '}
条记录
{t('records')}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -227,11 +231,13 @@ export function TableBrowser({
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1 || loadingData}
>
上一页
{t('prevPage')}
</Button>
<span className='text-xs px-2 font-mono'>
第 {page} /{' '}
{Math.max(1, Math.ceil(tableData.total / pageSize))} 页
{t('pageIndicator', {
current: page,
total: Math.max(1, Math.ceil(tableData.total / pageSize)),
})}
</span>
<Button
variant='outline'
@@ -242,7 +248,7 @@ export function TableBrowser({
page >= Math.ceil(tableData.total / pageSize) || loadingData
}
>
下一页
{t('nextPage')}
</Button>
</div>
</div>
@@ -250,7 +256,7 @@ export function TableBrowser({
) : (
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
<Layers className='size-8 opacity-45 mb-2' />
<span className='text-xs'>未选择数据表或表结构无法加载</span>
<span className='text-xs'>{t('noTableSelected')}</span>
</div>
)}
</CardContent>
@@ -16,6 +16,7 @@ import {
Server,
Terminal,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -57,6 +58,7 @@ const formatNumber = (num: number | string) => {
};
export function DatabasePageClient() {
const t = useTranslations('admin.database');
// 核心状态
const [overview, setOverview] = useState<DBOverview | null>(null);
const [tables, setTables] = useState<string[]>([]);
@@ -69,19 +71,22 @@ export function DatabasePageClient() {
const [refreshTrigger, setRefreshTrigger] = useState<number>(0);
// 1. 获取运行概览
const fetchOverview = useCallback(async (isSilent = false) => {
if (!isSilent) setLoadingOverview(true);
try {
const data = await services.dbManage.getOverview();
setOverview(data);
} catch (err) {
toast.error('获取数据库概览失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingOverview(false);
}
}, []);
const fetchOverview = useCallback(
async (isSilent = false) => {
if (!isSilent) setLoadingOverview(true);
try {
const data = await services.dbManage.getOverview();
setOverview(data);
} catch (err) {
toast.error(t('fetchOverviewFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingOverview(false);
}
},
[t],
);
// 2. 获取表列表
const fetchTables = useCallback(async () => {
@@ -90,13 +95,13 @@ export function DatabasePageClient() {
const data = await services.dbManage.listTables();
setTables(data);
} catch (err) {
toast.error('获取数据库数据表列表失败', {
toast.error(t('fetchTablesFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingTables(false);
}
}, []);
}, [t]);
// 3. 协调刷新
const handleRefreshAll = () => {
@@ -118,11 +123,11 @@ export function DatabasePageClient() {
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success('数据库导出成功', {
description: `已下载归档文件: ${filename}`,
toast.success(t('exportSuccess'), {
description: t('exportSuccessDesc', { filename }),
});
} catch (err) {
toast.error('数据库导出失败', {
toast.error(t('exportFailed'), {
description: err instanceof Error ? err.message : '导出异常',
});
} finally {
@@ -170,7 +175,9 @@ export function DatabasePageClient() {
<div className='flex items-center gap-2'>
<Database 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>
<Button
@@ -183,7 +190,7 @@ export function DatabasePageClient() {
<RefreshCw
className={`size-3 ${loadingOverview ? 'animate-spin' : ''}`}
/>
刷新数据
{t('refreshData')}
</Button>
</div>
@@ -196,7 +203,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
数据库类型
{t('dbType')}
</CardDescription>
<Server className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -211,7 +218,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300 col-span-1 md:col-span-2 lg:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
版本信息
{t('versionInfo')}
</CardDescription>
<Cpu className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -229,7 +236,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
名称/路径
{t('nameOrPath')}
</CardDescription>
<Database className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -249,7 +256,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
数据库大小
{t('dbSize')}
</CardDescription>
<HardDrive className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -262,7 +269,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
物理数据表
{t('physicalTables')}
</CardDescription>
<Layers className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -277,7 +284,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
活跃连接数
{t('activeConnections')}
</CardDescription>
<Activity className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -303,9 +310,11 @@ export function DatabasePageClient() {
{/* 4. 底部功能卡片区 */}
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
<CardHeader className='pb-3 border-b border-dashed'>
<CardTitle className='text-sm font-semibold'>功能区</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('featureArea')}
</CardTitle>
<CardDescription className='text-[11px]'>
数据库导出及自定义高级 SQL 执行终端
{t('featureAreaDesc')}
</CardDescription>
</CardHeader>
<CardContent className='pt-4'>
@@ -315,11 +324,10 @@ export function DatabasePageClient() {
<div className='space-y-1 pr-4'>
<p className='text-xs font-semibold flex items-center gap-1.5'>
<Download className='size-4 text-primary' />
数据库备份导出
{t('dbBackupExport')}
</p>
<p className='text-[10px] text-muted-foreground'>
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL
导出为打包的 .sql 文本文件)
{t('dbBackupExportDesc')}
</p>
</div>
<Button
@@ -332,7 +340,7 @@ export function DatabasePageClient() {
<RefreshCw
className={`size-3 ${exporting ? 'animate-spin' : ''}`}
/>
{exporting ? '正在准备...' : '开始导出'}
{exporting ? t('preparing') : t('startExport')}
</Button>
</div>
@@ -341,11 +349,10 @@ export function DatabasePageClient() {
<div className='space-y-1 pr-4'>
<p className='text-xs font-semibold flex items-center gap-1.5'>
<Terminal className='size-4 text-primary' />
SQL 查询控台
{t('sqlQueryConsole')}
</p>
<p className='text-[10px] text-muted-foreground'>
打开在线 SQL 控制终端,执行原生的 SQL
进行数据筛选、更新、调试或性能优化
{t('sqlQueryConsoleDesc')}
</p>
</div>
<Button
@@ -356,7 +363,7 @@ export function DatabasePageClient() {
}}
>
<FileText className='size-3.5' />
进入控台
{t('enterConsole')}
</Button>
</div>
</div>
+31 -29
View File
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
import {
@@ -21,6 +22,7 @@ import { TableTab } from './tabs/table-tab';
import { ControlsTab } from './tabs/controls-tab';
export default function HeaderDemoPage() {
const t = useTranslations('admin.demo');
const [activeTab, setActiveTab] = React.useState('dashboard');
return (
@@ -30,7 +32,7 @@ export default function HeaderDemoPage() {
<Code className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
开发规范示例
{t('pageTitle')}
</h1>
</div>
</div>
@@ -40,28 +42,28 @@ export default function HeaderDemoPage() {
<CardHeader className='pb-3'>
<div className='flex items-center gap-2'>
<Code className='size-4 text-primary' />
<CardTitle className='text-base font-semibold'>规范说明</CardTitle>
<CardTitle className='text-base font-semibold'>
{t('specTitle')}
</CardTitle>
</div>
<CardDescription>
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
</CardDescription>
<CardDescription>{t('specDescription')}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<h3 className='text-xs font-semibold uppercase tracking-wider text-muted-foreground'>
核心规范细节与编写原则:
{t('coreSpecTitle')}
</h3>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
<strong>结构专注</strong>
:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。
<strong>{t('rules.structureFocus.title')}</strong>
{t('rules.structureFocus.description')}
</li>
<li>
<strong>容器与上边距对齐</strong>:使用{' '}
<strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2
</code>{' '}
作为标题行基础容器(不含下边框),外层统一使用{' '}
{t('rules.containerAlignment.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 px-1
</code>{' '}
@@ -69,53 +71,53 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6
</code>
) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。
) {t('rules.containerAlignment.description2')}
</li>
<li>
<strong>图标呈现</strong>:直接将 Lucide
图标组件嵌套于标题容器中,应用{' '}
<strong>{t('rules.iconPresentation.title')}</strong>:直接将
Lucide 图标组件嵌套于标题容器中,应用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
size-5 text-primary
</code>{' '}
样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。
{t('rules.iconPresentation.description')}
</li>
<li>
<strong>文本样式标准</strong>:文本使用且仅使用{' '}
<strong>{t('rules.textStyle.title')}</strong>:文本使用且仅使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'}
</code>
,禁用字重加粗(如{' '}
{t('rules.textStyle.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-bold
</code>
)或任何渐变色,保证全站字体一致性。
{t('rules.textStyle.description2')}
</li>
<li>
<strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab
页切换的页面,**禁止**将所有 Tab
<strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
Tab 页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx
</code>{' '}
或就近放在{' '}
{t('rules.tabsModular.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/
</code>{' '}
下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。
{t('rules.tabsModular.description2')}
</li>
<li>
<strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs
切换,当一个页面代码行数超过 600
行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如{' '}
<strong>{t('rules.complexityDriven.title')}</strong>:不仅是
Tabs 切换,当一个页面代码行数超过 600
{t('rules.complexityDriven.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/
</code>
),只有真正跨页面复用的组件才进入全局{' '}
{t('rules.complexityDriven.description2')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/common/
</code>
。典型标杆案例参考“数据管理” (
{t('rules.complexityDriven.description3')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
/admin/database
</code>
@@ -145,19 +147,19 @@ export default function HeaderDemoPage() {
value='dashboard'
className='px-0 pb-2 text-xs font-semibold'
>
仪表盘与指标卡片
{t('tabs.dashboard')}
</TabsTrigger>
<TabsTrigger
value='table'
className='px-0 pb-2 text-xs font-semibold'
>
数据表格规范
{t('tabs.table')}
</TabsTrigger>
<TabsTrigger
value='controls'
className='px-0 pb-2 text-xs font-semibold'
>
输入控件与表单
{t('tabs.controls')}
</TabsTrigger>
</TabsList>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
import {
Card,
@@ -25,6 +26,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
export function ControlsTab() {
const t = useTranslations('admin.demo.controls');
const [formData, setFormData] = React.useState({
name: '',
category: 'database',
@@ -38,11 +40,9 @@ export function ControlsTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold'>
表单与输入框交互演示
{t('formDemoTitle')}
</CardTitle>
<CardDescription>
包含按钮变体、下拉选择、多选框及基础输入框的标准样式
</CardDescription>
<CardDescription>{t('formDemoDesc')}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{/* 输入框 */}
@@ -51,7 +51,7 @@ export function ControlsTab() {
htmlFor='task-name'
className='text-xs font-medium text-foreground'
>
任务名称
{t('taskName')}
</Label>
<Input
id='task-name'
@@ -59,18 +59,18 @@ export function ControlsTab() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder='输入要创建的任务描述...'
placeholder={t('taskNamePlaceholder')}
className='h-8 text-xs shadow-none bg-background focus-visible:ring-1'
/>
<p className='text-[10px] text-muted-foreground'>
任务名称将用于后台执行记录的可读性显示
{t('taskNameHint')}
</p>
</div>
{/* 下拉框 */}
<div className='space-y-1.5'>
<Label className='text-xs font-medium text-foreground'>
任务分类
{t('taskCategory')}
</Label>
<Select
value={formData.category}
@@ -82,9 +82,11 @@ export function ControlsTab() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='database'>数据库运维</SelectItem>
<SelectItem value='sync'>数据批量同步</SelectItem>
<SelectItem value='cleanup'>过期磁盘清理</SelectItem>
<SelectItem value='database'>
{t('categoryDatabase')}
</SelectItem>
<SelectItem value='sync'>{t('categorySync')}</SelectItem>
<SelectItem value='cleanup'>{t('categoryCleanup')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -107,10 +109,10 @@ export function ControlsTab() {
htmlFor='allow-notify'
className='text-xs font-medium text-foreground cursor-pointer select-none leading-none'
>
允许触发异步通知推送
{t('allowNotify')}
</label>
<p className='text-[10px] text-muted-foreground leading-normal'>
勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。
{t('allowNotifyDesc')}
</p>
</div>
</div>
@@ -118,32 +120,32 @@ export function ControlsTab() {
{/* 按钮变体 */}
<div className='pt-2'>
<div className='text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2'>
按钮样式对齐:
{t('buttonStyles')}
</div>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' className='h-8 text-xs shadow-none'>
主要操作 (Primary)
{t('primaryButton')}
</Button>
<Button
variant='secondary'
size='sm'
className='h-8 text-xs shadow-none'
>
次要操作 (Secondary)
{t('secondaryButton')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8 border-dashed text-xs shadow-none'
>
辅助线框 (Outline)
{t('outlineButton')}
</Button>
<Button
variant='ghost'
size='sm'
className='h-8 text-xs hover:bg-muted'
>
幽灵按钮 (Ghost)
{t('ghostButton')}
</Button>
</div>
</div>
@@ -155,13 +157,13 @@ export function ControlsTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
表单设计规范
{t('formDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4 text-xs text-muted-foreground'>
<div className='space-y-2 leading-relaxed'>
<p className='font-semibold text-foreground text-[11px]'>
1. 统一控件高度与投影:
{t('spec.unifiedHeight')}
</p>
<p>
输入框 (
@@ -188,7 +190,7 @@ export function ControlsTab() {
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
2. Checkbox 与文本的对齐:
{t('spec.checkboxAlignment')}
</p>
<p>
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
@@ -203,7 +205,7 @@ export function ControlsTab() {
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
3. 按钮使用策略:
{t('spec.buttonStrategy')}
</p>
<p>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
@@ -4,11 +4,13 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Cpu, Database, HardDrive } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
export function DashboardTab() {
const t = useTranslations('admin.demo.dashboard');
return (
<div className='space-y-6'>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
@@ -16,7 +18,7 @@ export function DashboardTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
容器 CPU 使用率
{t('cpuUsage')}
</span>
<Cpu className='size-4 text-primary' />
</CardHeader>
@@ -25,7 +27,7 @@ export function DashboardTab() {
<Progress value={42.8} className='h-1.5' />
<p className='text-[10px] text-muted-foreground flex items-center gap-1'>
<span className='size-1.5 rounded-full bg-emerald-500 inline-block animate-pulse' />
容器运行正常,负载平稳
{t('cpuStatus')}
</p>
</CardContent>
</Card>
@@ -34,7 +36,7 @@ export function DashboardTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
存储已用容量
{t('storageUsage')}
</span>
<HardDrive className='size-4 text-primary' />
</CardHeader>
@@ -45,7 +47,7 @@ export function DashboardTab() {
</div>
<Progress value={72.4} className='h-1.5' />
<p className='text-[10px] text-muted-foreground'>
数据已同步远端 S3 存储桶,支持自动过期
{t('storageStatus')}
</p>
</CardContent>
</Card>
@@ -54,7 +56,7 @@ export function DashboardTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
物理数据库
{t('database')}
</span>
<Database className='size-4 text-primary' />
</CardHeader>
@@ -67,17 +69,15 @@ export function DashboardTab() {
</div>
<div className='flex items-center gap-3 text-xs text-muted-foreground'>
<div>
活跃连接:{' '}
{t('activeConnections')}{' '}
<span className='font-semibold text-foreground'>12</span>
</div>
<div>
总表数量:{' '}
{t('totalTables')}{' '}
<span className='font-semibold text-foreground'>34</span>
</div>
</div>
<p className='text-[10px] text-muted-foreground'>
链接池自动维护中,最大连接限制为 100
</p>
<p className='text-[10px] text-muted-foreground'>{t('dbStatus')}</p>
</CardContent>
</Card>
</div>
@@ -87,45 +87,45 @@ export function DashboardTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
指标卡片 & 仪表盘设计规范
{t('dashboardDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
指标卡片统一采用{' '}
{t('spec.dashedBorder')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none
</code>{' '}
作为边框和投影。
{t('spec.dashedBorder2')}
</li>
<li>
头部使用{' '}
{t('spec.headerLayout')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex flex-row items-center justify-between pb-2
</code>{' '}
布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。
{t('spec.headerLayout2')}
</li>
<li>
数值强调使用且仅使用{' '}
{t('spec.valueStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-2xl font-semibold tracking-tight
</code>{' '}
展示。
{t('spec.valueStyle2')}
</li>
<li>
进度条使用{' '}
{t('spec.progressBar')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-1.5
</code>{' '}
细条高度,不喧宾夺主。
{t('spec.progressBar2')}
</li>
<li>
底部的次要说明信息必须统一使用{' '}
{t('spec.secondaryText')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-[10px] text-muted-foreground
</code>{' '}
等辅助类。
{t('spec.secondaryText2')}
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -19,27 +20,28 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TableTab() {
const t = useTranslations('admin.demo.table');
const mockTableData = [
{
id: 'TX-1002',
name: '用户信息批量同步导出',
type: '数据同步',
name: t('mockData.syncExport'),
type: t('mockType.dataSync'),
status: 'success',
duration: '1.2s',
time: '2026-06-15 14:22',
},
{
id: 'TX-1003',
name: '系统数据库每周自动备份归档',
type: '数据库运维',
name: t('mockData.dbBackup'),
type: t('mockType.dbOps'),
status: 'running',
duration: '在执行中',
duration: t('mockDuration.running'),
time: '2026-06-15 15:00',
},
{
id: 'TX-1004',
name: '远端S3过期缓存对象自动清理任务',
type: '磁盘清理',
name: t('mockData.s3Cleanup'),
type: t('mockType.diskCleanup'),
status: 'failed',
duration: '0.4s',
time: '2026-06-15 10:05',
@@ -54,7 +56,7 @@ export function TableTab() {
<div className='relative w-64'>
<Search className='absolute left-2.5 top-2.5 size-3 text-muted-foreground' />
<Input
placeholder='输入任务名称搜索...'
placeholder={t('searchPlaceholder')}
className='h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background'
/>
</div>
@@ -64,12 +66,12 @@ export function TableTab() {
className='h-8 border-dashed text-xs shadow-none'
>
<ListFilter className='size-3 mr-1' />
过滤
{t('filter')}
</Button>
</div>
<Button size='sm' className='h-8 text-xs shadow-none'>
<Plus className='size-3.5 mr-1' />
新建任务
{t('newTask')}
</Button>
</div>
@@ -79,25 +81,25 @@ export function TableTab() {
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='w-[100px] text-xs font-semibold'>
任务编号
{t('columns.taskId')}
</TableHead>
<TableHead className='text-xs font-semibold'>
执行任务名称
{t('columns.taskName')}
</TableHead>
<TableHead className='w-[120px] text-xs font-semibold'>
任务类型
{t('columns.taskType')}
</TableHead>
<TableHead className='w-[100px] text-xs font-semibold'>
状态
{t('columns.status')}
</TableHead>
<TableHead className='w-[100px] text-xs font-semibold'>
执行耗时
{t('columns.duration')}
</TableHead>
<TableHead className='w-[150px] text-xs font-semibold'>
触发时间
{t('columns.triggerTime')}
</TableHead>
<TableHead className='w-[80px] text-xs font-semibold text-right'>
操作
{t('columns.actions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -123,7 +125,7 @@ export function TableTab() {
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
成功
{t('status.success')}
</Badge>
)}
{row.status === 'running' && (
@@ -132,7 +134,7 @@ export function TableTab() {
className='text-[10px] bg-blue-500/10 border-blue-500/20 text-blue-600 rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-blue-500 rounded-full mr-1.5 shrink-0 animate-pulse' />
运行中
{t('status.running')}
</Badge>
)}
{row.status === 'failed' && (
@@ -141,7 +143,7 @@ export function TableTab() {
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
失败
{t('status.failed')}
</Badge>
)}
</TableCell>
@@ -180,13 +182,13 @@ export function TableTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
表格设计规范与代码模板
{t('tableDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
表格容器必须统一使用带有虚线边框的容器,即类名:
{t('spec.container')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border border-dashed shadow-none rounded-lg overflow-hidden
bg-background
@@ -194,40 +196,40 @@ export function TableTab() {
。
</li>
<li>
表头背景设为{' '}
{t('spec.headerBg')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
bg-muted/40
</code>
。表头表项字号统一为小字粗体{' '}
{t('spec.headerStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs font-semibold
</code>
。
</li>
<li>
单元格行与列之间使用虚线分割(
{t('spec.cellDivider')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed
</code>
)。表格行悬浮交互采用温和过渡色{' '}
{t('spec.rowHover')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
hover:bg-muted/10 transition-colors
</code>
。
</li>
<li>
编码、主键或唯一 ID 类字段一律采用等宽字体呈现:
{t('spec.monoFont')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-mono text-xs
</code>
。
</li>
<li>
状态标签(Badge)尽量圆角化(
{t('spec.badgeStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
rounded-full py-0 px-2
</code>
),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。
{t('spec.badgeStyle2')}
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
@@ -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
+8 -4
View File
@@ -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>
@@ -28,6 +28,7 @@ import {
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -83,6 +84,7 @@ function formatDateLabel(dateStr: string) {
}
export function AccessAnalytics() {
const t = useTranslations('admin.logs.analytics');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [clickhouseDisabled, setClickhouseDisabled] = useState(false);
@@ -135,11 +137,9 @@ export function AccessAnalytics() {
return (
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
<XCircle className='size-10 text-muted-foreground mb-3' />
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
<h3 className='text-base font-semibold'>{t('clickhouseDisabled')}</h3>
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
{t('clickhouseDisabledDesc')}
</p>
</div>
);
@@ -153,7 +153,7 @@ export function AccessAnalytics() {
return (
<LoadingStateWithBorder
icon={BarChart3}
description='加载访问统计指标中...'
description={t('loadingAnalytics')}
/>
);
}
@@ -165,7 +165,7 @@ export function AccessAnalytics() {
<Card className='bg-card/25 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
近 7 天总请求数
{t('totalRequests7d')}
</CardTitle>
<TrendingUp className='size-4 text-muted-foreground' />
</CardHeader>
@@ -174,14 +174,14 @@ export function AccessAnalytics() {
{totalTrendRequests.toLocaleString()}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
系统记录的所有成功认证的访问总频次
{t('totalRequests7dDesc')}
</p>
</CardContent>
</Card>
<Card className='bg-card/25 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
活跃终端分类数
{t('activeBrowserTypes')}
</CardTitle>
<Globe className='size-4 text-muted-foreground' />
</CardHeader>
@@ -190,14 +190,14 @@ export function AccessAnalytics() {
{browsers.length}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
在一周内发起请求的浏览器代理大类统计
{t('activeBrowserTypesDesc')}
</p>
</CardContent>
</Card>
<Card className='bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
活跃独立用户数
{t('activeUsers')}
</CardTitle>
<Users className='size-4 text-muted-foreground' />
</CardHeader>
@@ -206,7 +206,7 @@ export function AccessAnalytics() {
{topUsers.length}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
一周内累计发起高频请求的注册账户总量
{t('activeUsersDesc')}
</p>
</CardContent>
</Card>
@@ -217,10 +217,10 @@ export function AccessAnalytics() {
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base font-bold'>
一周访问量趋势
{t('weeklyAccessTrend')}
</CardTitle>
<CardDescription className='text-xs'>
展现系统最近 7 天内的每日 API 请求曲线
{t('weeklyAccessTrendDesc')}
</CardDescription>
</div>
<Button
@@ -241,7 +241,7 @@ export function AccessAnalytics() {
{trend.length === 0 ? (
<EmptyStateWithBorder
icon={TrendingUp}
description='暂无趋势数据'
description={t('noTrendData')}
/>
) : (
<div className='h-[280px] w-full'>
@@ -305,17 +305,17 @@ export function AccessAnalytics() {
<CardHeader>
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Globe className='size-4.5 text-muted-foreground' />
使用的浏览器排行
{t('browserRanking')}
</CardTitle>
<CardDescription className='text-xs'>
基于请求头 User-Agent 智能分类的一周占比统计
{t('browserRankingDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1'>
{browsers.length === 0 ? (
<EmptyStateWithBorder
icon={Globe}
description='暂无浏览器排行数据'
description={t('noBrowserData')}
/>
) : (
<div className='space-y-4'>
@@ -360,17 +360,17 @@ export function AccessAnalytics() {
<CardHeader>
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Users className='size-4.5 text-muted-foreground' />
最活跃的用户排行 (Top 10)
{t('topActiveUsers')}
</CardTitle>
<CardDescription className='text-xs'>
统计最近一周发起接口访问请求数量最多的账户
{t('topActiveUsersDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1'>
{topUsers.length === 0 ? (
<EmptyStateWithBorder
icon={Users}
description='暂无活跃用户数据'
description={t('noActiveUsersData')}
/>
) : (
<div className='divide-y divide-border/40'>
@@ -385,11 +385,13 @@ export function AccessAnalytics() {
</div>
<div className='truncate max-w-[180px] sm:max-w-xs'>
<div className='text-xs font-bold leading-tight truncate'>
{user.username || '未知'}
{user.username || t('unknown')}
</div>
<div className='text-[10px] text-muted-foreground leading-normal truncate'>
{user.nickname ? `(${user.nickname})` : '(无昵称)'} |
ID: {user.user_id}
{user.nickname
? `(${user.nickname})`
: t('noNickname')}{' '}
| ID: {user.user_id}
</div>
</div>
</div>
@@ -398,7 +400,7 @@ export function AccessAnalytics() {
{user.count.toLocaleString()}
</div>
<div className='text-[9px] text-muted-foreground uppercase font-bold tracking-wider'>
次请求
{t('requests')}
</div>
</div>
</div>
@@ -32,6 +32,7 @@ import {
} from 'lucide-react';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -97,6 +98,7 @@ function isClickhouseDisabledError(error: Error) {
}
export function AccessLogs() {
const t = useTranslations('admin.logs.accessLogs');
const [page, setPage] = useState(1);
// 搜索过滤条件
@@ -202,11 +204,9 @@ export function AccessLogs() {
return (
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
<XCircle className='size-10 text-muted-foreground mb-3' />
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
<h3 className='text-base font-semibold'>{t('clickhouseDisabled')}</h3>
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
{t('clickhouseDisabledDesc')}
</p>
</div>
);
@@ -227,11 +227,11 @@ export function AccessLogs() {
>
<div className='grid gap-1.5'>
<Label htmlFor='search-user' className='text-xs'>
用户名
{t('username')}
</Label>
<Input
id='search-user'
placeholder='输入用户名搜索...'
placeholder={t('usernamePlaceholder')}
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
className='h-8 text-xs'
@@ -239,11 +239,11 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-path' className='text-xs'>
接口路径
{t('apiPath')}
</Label>
<Input
id='search-path'
placeholder='模糊匹配路径...'
placeholder={t('pathPlaceholder')}
value={pathFilter}
onChange={(e) => setPathFilter(e.target.value)}
className='h-8 text-xs'
@@ -251,7 +251,7 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-start' className='text-xs'>
起始时间
{t('startTime')}
</Label>
<Input
id='search-start'
@@ -263,7 +263,7 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-end' className='text-xs'>
结束时间
{t('endTime')}
</Label>
<Input
id='search-end'
@@ -276,7 +276,7 @@ export function AccessLogs() {
<div className='flex gap-2'>
<Button type='submit' size='sm' className='h-8 flex-1'>
<Search className='size-3.5 mr-1' />
搜索
{t('search')}
</Button>
<Button
type='button'
@@ -286,7 +286,7 @@ export function AccessLogs() {
className='h-8 flex-1'
>
<RotateCcw className='size-3.5 mr-1' />
重置
{t('reset')}
</Button>
</div>
</form>
@@ -295,26 +295,27 @@ export function AccessLogs() {
{loading && logs.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载访问日志中...'
description={t('loadingAccessLogs')}
/>
) : logs.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
description='未查询到任何访问日志数据'
/>
<EmptyStateWithBorder icon={Activity} description={t('noAccessLogs')} />
) : (
<div className='rounded-lg border bg-card overflow-hidden'>
<Table className='min-w-[900px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[100px]'>请求方法</TableHead>
<TableHead className='min-w-[200px]'>路径</TableHead>
<TableHead className='w-[150px]'>用户</TableHead>
<TableHead className='w-[120px]'>IP</TableHead>
<TableHead className='w-[90px]'>状态</TableHead>
<TableHead className='w-[100px]'>耗时</TableHead>
<TableHead className='w-[170px]'>请求时间</TableHead>
<TableHead className='w-[80px] text-center'>详情</TableHead>
<TableHead className='w-[100px]'>{t('colMethod')}</TableHead>
<TableHead className='min-w-[200px]'>{t('colPath')}</TableHead>
<TableHead className='w-[150px]'>{t('colUser')}</TableHead>
<TableHead className='w-[120px]'>{t('colIp')}</TableHead>
<TableHead className='w-[90px]'>{t('colStatus')}</TableHead>
<TableHead className='w-[100px]'>{t('colLatency')}</TableHead>
<TableHead className='w-[170px]'>
{t('colRequestTime')}
</TableHead>
<TableHead className='w-[80px] text-center'>
{t('colDetail')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -334,7 +335,7 @@ export function AccessLogs() {
<TableCell>
<div className='flex flex-col'>
<span className='text-xs font-medium'>
{log.username || '未知用户'}
{log.username || t('unknownUser')}
</span>
{log.nickname && (
<span className='text-[10px] text-muted-foreground'>
@@ -381,7 +382,7 @@ export function AccessLogs() {
{logs.length > 0 && (
<div className='flex items-center justify-between py-1'>
<div className='text-xs text-muted-foreground'>
共 {total} 条记录,当前第 {page}/{totalPages} 页
{t('paginationInfo', { total, page, totalPages })}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -392,7 +393,7 @@ export function AccessLogs() {
className='h-8 text-xs'
>
<ChevronLeft className='size-3.5 mr-1' />
上一页
{t('prevPage')}
</Button>
<Button
variant='outline'
@@ -401,7 +402,7 @@ export function AccessLogs() {
disabled={page >= totalPages || loading}
className='h-8 text-xs'
>
下一页
{t('nextPage')}
<ChevronRight className='size-3.5 ml-1' />
</Button>
</div>
@@ -412,7 +413,9 @@ export function AccessLogs() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l'>
<SheetHeader className='border-b px-5 py-4 shrink-0'>
<SheetTitle className='text-lg font-bold'>访问日志详情</SheetTitle>
<SheetTitle className='text-lg font-bold'>
{t('accessLogDetail')}
</SheetTitle>
<SheetDescription className='text-xs'>
访问 ID: {selectedLog?.id}
</SheetDescription>
@@ -424,7 +427,7 @@ export function AccessLogs() {
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3'>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
请求方法
{t('method')}
</div>
<div className='mt-1 font-mono text-sm font-bold uppercase'>
{selectedLog.method}
@@ -432,7 +435,7 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
响应状态
{t('responseStatus')}
</div>
<div className='mt-1'>
<Badge variant={statusVariant(selectedLog.status)}>
@@ -442,7 +445,7 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
耗时
{t('latency')}
</div>
<div className='mt-1 font-mono text-sm'>
{formatLatency(selectedLog.latency)}
@@ -450,18 +453,18 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
IP 地址
{t('ipAddress')}
</div>
<div className='mt-1 font-mono text-sm'>{selectedLog.ip}</div>
</div>
<div className='rounded-lg border p-3 bg-muted/10 col-span-2'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
用户
{t('user')}
</div>
<div className='mt-1 text-sm font-medium'>
{selectedLog.username
? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})`
: '未知/游客'}
? `${selectedLog.username} (${selectedLog.nickname || t('noNickname')})`
: t('unknownGuest')}
<span className='block font-mono text-[10px] text-muted-foreground mt-0.5'>
ID: {selectedLog.user_id}
</span>
@@ -472,7 +475,7 @@ export function AccessLogs() {
{/* Path */}
<div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'>
请求路径
{t('requestPath')}
</Label>
<div className='rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all'>
{selectedLog.path}
@@ -482,7 +485,7 @@ export function AccessLogs() {
{/* Request Time */}
<div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'>
请求时间
{t('requestTime')}
</Label>
<div className='font-mono text-xs'>
{formatDateTime(selectedLog.created_at)}
@@ -542,7 +545,7 @@ export function AccessLogs() {
<div className='flex-grow flex items-center justify-center'>
<EmptyStateWithBorder
icon={Activity}
description='未选中日志明细'
description={t('noLogSelected')}
/>
</div>
)}
@@ -22,6 +22,7 @@ import { toast } from 'sonner';
import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
@@ -92,6 +93,7 @@ function parseLogLevel(
}
export function AppLogs() {
const t = useTranslations('admin.logs.appLogs');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
@@ -300,12 +302,12 @@ export function AppLogs() {
{loadingMore ? (
<>
<Loader2 className='size-3 mr-1.5 animate-spin' />
加载中...
{t('loading')}
</>
) : (
<>
<ChevronUp className='size-3 mr-1.5' />
加载更早日志
{t('loadOlderLogs')}
</>
)}
</Button>
@@ -313,7 +315,9 @@ export function AppLogs() {
)}
{logs.length === 0 ? (
<div className='px-4 py-8 text-center text-gray-500'>暂无日志</div>
<div className='px-4 py-8 text-center text-gray-500'>
{t('noLogs')}
</div>
) : (
<div
className='relative px-3 py-3'
@@ -349,7 +353,7 @@ export function AppLogs() {
className='shadow-lg bg-background/80 backdrop-blur-sm border-border/50'
>
<ArrowDown className='size-3.5 mr-1.5' />
回到最新
{t('backToLatest')}
</Button>
</div>
)}
@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic';
import { Activity, BarChart3, Terminal } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -26,13 +27,16 @@ const AppLogs = dynamic(
);
export function LogsPageClient() {
const t = useTranslations('admin.logs');
return (
<div className='flex flex-col h-full space-y-6 py-6'>
{/* Header */}
<div className='flex items-center gap-2'>
<Terminal 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>
{/* Tabs Layout */}
@@ -43,21 +47,21 @@ export function LogsPageClient() {
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<BarChart3 className='size-3.5' />
访问分析
{t('accessAnalytics')}
</TabsTrigger>
<TabsTrigger
value='access'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<Activity className='size-3.5' />
用户访问日志
{t('userAccessLogs')}
</TabsTrigger>
<TabsTrigger
value='app'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<Terminal className='size-3.5' />
应用运行日志
{t('appRuntimeLogs')}
</TabsTrigger>
</TabsList>
@@ -57,6 +57,8 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -70,6 +72,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function EventsTab() {
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
// --- 获取所有自定义消息通道 ---
@@ -107,12 +110,12 @@ export function EventsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) =>
PushService.updateEvent(id, data),
onSuccess: () => {
toast.success('事件更新成功');
toast.success(t('eventUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setEditEventOpen(false);
},
onError: (err: unknown) => {
toast.error('事件更新失败: ' + (err as Error).message);
toast.error(t('eventUpdateFailed') + ': ' + (err as Error).message);
},
});
@@ -122,14 +125,14 @@ export function EventsTab() {
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
onError: (err: unknown) => {
toast.error('操作失败: ' + (err as Error).message);
toast.error(t('operationFailed') + ': ' + (err as Error).message);
},
});
const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
onSuccess: () => {
toast.success('事件创建成功');
toast.success(t('eventCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setCreateEventOpen(false);
setNewEventKey('');
@@ -139,18 +142,18 @@ export function EventsTab() {
setNewEventEnabled(true);
},
onError: (err: unknown) => {
toast.error('事件创建失败: ' + (err as Error).message);
toast.error(t('eventCreateFailed') + ': ' + (err as Error).message);
},
});
const deleteEventMutation = useMutation({
mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => {
toast.success('配置删除成功');
toast.success(t('configDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
onError: (err: unknown) => {
toast.error('配置删除失败: ' + (err as Error).message);
toast.error(t('configDeleteFailed') + ': ' + (err as Error).message);
},
});
@@ -198,7 +201,7 @@ export function EventsTab() {
try {
JSON.parse(eventTemplate);
} catch {
toast.error('消息模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
@@ -258,11 +261,11 @@ export function EventsTab() {
const handleCreateEvent = () => {
if (newEventType === 'builtin' && !newEventKey) {
toast.error('请选择系统事件');
toast.error(t('selectSystemEvent'));
return;
}
if (newEventType === 'task' && !newEventTaskType) {
toast.error('请选择异步任务');
toast.error(t('selectAsyncTask'));
return;
}
@@ -270,7 +273,7 @@ export function EventsTab() {
try {
JSON.parse(newEventTemplate);
} catch {
toast.error('内容渲染模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
}
@@ -295,11 +298,14 @@ export function EventsTab() {
<div className='flex justify-end'>
<Button size='sm' onClick={handleCreateEventClick} className='text-xs'>
<Plus className='size-3.5 mr-1' />
新增通知事件
{t('addEvent')}
</Button>
</div>
{eventsQuery.isLoading ? (
<LoadingStateWithBorder icon={Layers} description='加载通知事件中...' />
<LoadingStateWithBorder
icon={Layers}
description={t('loadingEvents')}
/>
) : eventsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'>
<ErrorInline
@@ -309,10 +315,7 @@ export function EventsTab() {
/>
</div>
) : (eventsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
icon={Layers}
description='暂无通知配置事件,请点击右上角新增'
/>
<EmptyStateWithBorder icon={Layers} description={t('noEvents')} />
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<Table className='w-full caption-bottom text-sm min-w-full'>
@@ -322,19 +325,19 @@ export function EventsTab() {
ID
</TableHead>
<TableHead className='w-[180px] whitespace-nowrap py-2 h-8'>
通知事件
{t('colEvent')}
</TableHead>
<TableHead className='w-[200px] whitespace-nowrap py-2 h-8'>
关联渠道
{t('colChannels')}
</TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'>
推送目标
{t('colTargets')}
</TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态
{t('colStatus')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -362,7 +365,7 @@ export function EventsTab() {
variant='outline'
className='text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200'
>
任务
{t('taskBadge')}
</Badge>
)}
</div>
@@ -375,7 +378,7 @@ export function EventsTab() {
<div className='flex flex-wrap gap-1'>
{(event.channels ?? []).length === 0 ? (
<span className='text-xs text-muted-foreground italic'>
未指定渠道
{t('noChannelSpecified')}
</span>
) : (
event.channels.map((ch) => (
@@ -384,7 +387,7 @@ export function EventsTab() {
variant='secondary'
className='text-[10px] py-0 px-1.5 h-4.5'
>
{ch === 'email' ? '邮件' : ch}
{ch === 'email' ? t('emailChannel') : ch}
</Badge>
))
)}
@@ -439,7 +442,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
配置
{t('configure')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -453,7 +456,7 @@ export function EventsTab() {
className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10'
disabled={deleteEventMutation.isPending}
onClick={() => {
if (confirm('确定要删除该通知事件配置吗?')) {
if (confirm(t('deleteEventConfirm'))) {
deleteEventMutation.mutate(event.id);
}
}}
@@ -462,7 +465,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
删除
{t('delete')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -479,15 +482,13 @@ export function EventsTab() {
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>新增通知事件</DialogTitle>
<DialogDescription>
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
</DialogDescription>
<DialogTitle>{t('addEventDialogTitle')}</DialogTitle>
<DialogDescription>{t('addEventDialogDesc')}</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>事件类型</Label>
<Label className='text-xs font-semibold'>{t('eventType')}</Label>
<div className='flex gap-4 p-1.5 border rounded-md bg-muted/20'>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input
@@ -501,7 +502,7 @@ export function EventsTab() {
}}
className='scale-90'
/>
<span>系统内置事件</span>
<span>{t('builtinEventType')}</span>
</label>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input
@@ -515,22 +516,24 @@ export function EventsTab() {
}}
className='scale-90'
/>
<span>任务完成事件</span>
<span>{t('taskEventType')}</span>
</label>
</div>
</div>
{newEventType === 'builtin' ? (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统事件</Label>
<Label className='text-xs font-semibold'>
{t('systemEvent')}
</Label>
{builtInEventsQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
<span>加载系统事件中...</span>
<span>{t('loadingSystemEvents')}</span>
</div>
) : availableBuiltInEvents.length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
所有内置事件都已配置,没有可新增的事件。
{t('allBuiltinEventsConfigured')}
</div>
) : (
<Select
@@ -538,7 +541,9 @@ export function EventsTab() {
onValueChange={handleNewEventKeyChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择系统事件' />
<SelectValue
placeholder={t('selectSystemEventPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{availableBuiltInEvents.map((ev) => (
@@ -556,15 +561,17 @@ export function EventsTab() {
</div>
) : (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统异步任务</Label>
<Label className='text-xs font-semibold'>
{t('systemAsyncTask')}
</Label>
{taskTypesQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
<span>加载异步任务中...</span>
<span>{t('loadingAsyncTasks')}</span>
</div>
) : (taskTypesQuery.data ?? []).length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
暂无可用的系统任务。
{t('noAvailableTasks')}
</div>
) : (
<Select
@@ -572,7 +579,9 @@ export function EventsTab() {
onValueChange={handleNewEventTaskTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择异步任务' />
<SelectValue
placeholder={t('selectAsyncTaskPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{(taskTypesQuery.data ?? []).map((taskMeta) => (
@@ -593,11 +602,11 @@ export function EventsTab() {
{newEventType === 'builtin' && newEventKey && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'>
事件说明:
{t('eventDescription')}
</span>
<span>
{availableBuiltInEvents.find((e) => e.key === newEventKey)
?.description || '无描述信息'}
?.description || t('noDescription')}
</span>
</div>
)}
@@ -605,18 +614,20 @@ export function EventsTab() {
{newEventType === 'task' && newEventTaskType && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'>
任务说明:
{t('taskDescription')}
</span>
<span>
{(taskTypesQuery.data ?? []).find(
(t) => t.asynq_task === newEventTaskType,
)?.description || '无描述信息'}
)?.description || t('noDescription')}
</span>
</div>
)}
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>推送渠道 (可多选)</Label>
<Label className='text-xs font-semibold'>
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -661,7 +672,7 @@ export function EventsTab() {
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
{t('noAvailableChannels')}
</div>
)}
</div>
@@ -671,7 +682,7 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
@@ -685,12 +696,12 @@ export function EventsTab() {
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式)
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{newEventType === 'task'
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
? t('taskTemplateVars')
: t('eventTemplateVars')}
</span>
</div>
<Textarea
@@ -704,9 +715,11 @@ export function EventsTab() {
<div className='flex items-center justify-between p-3 border rounded-lg bg-muted/10'>
<div className='space-y-0.5'>
<Label className='text-xs font-semibold'>启用状态</Label>
<Label className='text-xs font-semibold'>
{t('enableStatus')}
</Label>
<div className='text-[10px] text-muted-foreground'>
创建后是否立即开始接收此事件的通知
{t('enableStatusDesc')}
</div>
</div>
<Switch
@@ -723,7 +736,7 @@ export function EventsTab() {
onClick={() => setCreateEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -739,7 +752,7 @@ export function EventsTab() {
{createEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
启用保存
{t('enableSave')}
</Button>
</DialogFooter>
</DialogContent>
@@ -749,10 +762,8 @@ export function EventsTab() {
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>事件通知渠道配置</DialogTitle>
<DialogDescription>
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
</DialogDescription>
<DialogTitle>{t('editEventDialogTitle')}</DialogTitle>
<DialogDescription>{t('editEventDialogDesc')}</DialogDescription>
</DialogHeader>
{selectedEvent && (
@@ -760,7 +771,7 @@ export function EventsTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
事件名称
{t('eventName')}
</Label>
<Input
value={selectedEvent.name}
@@ -770,7 +781,7 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
事件键 (Key)
{t('eventKey')}
</Label>
<Input
value={selectedEvent.event_key}
@@ -783,7 +794,7 @@ export function EventsTab() {
{selectedEvent.task_type && (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
关联异步任务
{t('relatedAsyncTask')}
</Label>
<Input
value={`${(taskTypesQuery.data ?? []).find((t) => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
@@ -795,7 +806,7 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送渠道 (可多选)
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
@@ -806,11 +817,11 @@ export function EventsTab() {
{eventChannels.length > 0
? eventChannels
.map((ch) => {
if (ch === 'email') return '邮件';
if (ch === 'email') return t('emailChannel');
return ch;
})
.join(', ')
: '选择已配置推送渠道'}
: t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
@@ -836,12 +847,12 @@ export function EventsTab() {
}
}}
/>
<span>{ch === 'email' ? '邮件推送' : ch}</span>
<span>{ch === 'email' ? t('emailPush') : ch}</span>
</label>
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
{t('noAvailableChannelsEdit')}
</div>
)}
</div>
@@ -851,11 +862,11 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
placeholder={t('pushTargetsPlaceholder')}
value={eventTargets}
onChange={(e) => setEventTargets(e.target.value)}
className='text-xs h-9'
@@ -865,12 +876,12 @@ export function EventsTab() {
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式)
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{selectedEvent.task_type
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
? t('taskTemplateVars')
: t('eventTemplateVars')}
</span>
</div>
<Textarea
@@ -891,7 +902,7 @@ export function EventsTab() {
onClick={() => setEditEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -903,7 +914,7 @@ export function EventsTab() {
{updateEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
保存修改
{t('saveChanges')}
</Button>
</DialogFooter>
</DialogContent>
@@ -35,6 +35,7 @@ import {
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import {
Dialog,
@@ -62,6 +63,7 @@ function getLevelBadgeVariant(
}
export function HistoriesTab() {
const t = useTranslations('admin.push.histories');
const [historyPage, setHistoryPage] = React.useState(1);
const [historySearch, setHistorySearch] = React.useState('');
const [historyStatus, setHistoryStatus] = React.useState('all');
@@ -99,7 +101,7 @@ export function HistoriesTab() {
<Search className='absolute left-2.5 top-2.5 size-4 text-muted-foreground' />
<Input
type='text'
placeholder='输入事件键过滤...'
placeholder={t('searchPlaceholder')}
className='pl-8 text-xs h-9'
value={historySearch}
onChange={(e) => {
@@ -117,17 +119,17 @@ export function HistoriesTab() {
}}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='发送状态' />
<SelectValue placeholder={t('sendStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all' className='text-xs'>
全部状态
{t('allStatus')}
</SelectItem>
<SelectItem value='success' className='text-xs'>
发送成功
{t('sendSuccess')}
</SelectItem>
<SelectItem value='failed' className='text-xs'>
发送失败
{t('sendFailed')}
</SelectItem>
</SelectContent>
</Select>
@@ -151,13 +153,27 @@ export function HistoriesTab() {
<Table>
<TableHeader>
<TableRow className='bg-muted/30'>
<TableHead className='text-xs font-semibold'>事件</TableHead>
<TableHead className='text-xs font-semibold'>渠道</TableHead>
<TableHead className='text-xs font-semibold'>目标</TableHead>
<TableHead className='text-xs font-semibold'>标题</TableHead>
<TableHead className='text-xs font-semibold'>等级</TableHead>
<TableHead className='text-xs font-semibold'>状态</TableHead>
<TableHead className='text-xs font-semibold'>时间</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colEvent')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colChannel')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTarget')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTitle')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colLevel')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colStatus')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTime')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -166,7 +182,7 @@ export function HistoriesTab() {
<TableCell colSpan={7} className='h-32'>
<LoadingStateWithBorder
icon={History}
description='加载推送历史记录中...'
description={t('loadingHistories')}
className='border-0 shadow-none'
/>
</TableCell>
@@ -187,7 +203,7 @@ export function HistoriesTab() {
colSpan={7}
className='h-32 text-center text-xs text-muted-foreground'
>
无推送历史数据
{t('noHistoryData')}
</TableCell>
</TableRow>
) : (
@@ -201,7 +217,9 @@ export function HistoriesTab() {
{hist.event_key}
</TableCell>
<TableCell className='text-xs uppercase font-semibold text-muted-foreground'>
{hist.channel === 'email' ? '邮件' : hist.channel}
{hist.channel === 'email'
? t('emailChannel')
: hist.channel}
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground max-w-[120px] truncate'
@@ -230,7 +248,7 @@ export function HistoriesTab() {
}
className='text-[10px] font-semibold'
>
{hist.status === 'success' ? '成功' : '失败'}
{hist.status === 'success' ? t('success') : t('failed')}
</Badge>
{hist.status !== 'success' && hist.error_msg && (
<div
@@ -253,7 +271,7 @@ export function HistoriesTab() {
{historiesQuery.data && historiesQuery.data.total > 0 && (
<div className='flex justify-between items-center text-xs text-muted-foreground'>
<span>共 {historiesQuery.data.total} 条历史记录</span>
<span>{t('totalRecords', { total: historiesQuery.data.total })}</span>
<div className='flex items-center gap-1.5'>
<Button
variant='outline'
@@ -284,10 +302,10 @@ export function HistoriesTab() {
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<History className='size-5 text-primary' />
推送通知详情
{t('pushNotificationDetail')}
</DialogTitle>
<DialogDescription>
查看该条通知发送的详细审计信息与内容
{t('pushNotificationDetailDesc')}
</DialogDescription>
</DialogHeader>
@@ -296,7 +314,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
事件标识
{t('eventIdentifier')}
</span>
<div className='font-mono bg-muted/40 p-2 rounded border'>
{selectedHistory.event_key}
@@ -304,11 +322,11 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送渠道
{t('sendChannel')}
</span>
<div className='bg-muted/40 p-2 rounded border uppercase font-medium'>
{selectedHistory.channel === 'email'
? '邮件推送'
? t('emailPush')
: selectedHistory.channel}
</div>
</div>
@@ -317,7 +335,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
推送目标
{t('pushTarget')}
</span>
<div
className='font-mono bg-muted/40 p-2 rounded border truncate'
@@ -328,7 +346,7 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送时间
{t('sendTime')}
</span>
<div className='bg-muted/40 p-2 rounded border'>
{new Date(selectedHistory.created_at).toLocaleString()}
@@ -339,7 +357,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知等级
{t('notificationLevel')}
</span>
<div>
<Badge
@@ -352,7 +370,7 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送状态
{t('sendStatus')}
</span>
<div>
<Badge
@@ -363,7 +381,9 @@ export function HistoriesTab() {
}
className='text-[10px] font-semibold py-0.5 px-2'
>
{selectedHistory.status === 'success' ? '成功' : '失败'}
{selectedHistory.status === 'success'
? t('success')
: t('failed')}
</Badge>
</div>
</div>
@@ -373,7 +393,7 @@ export function HistoriesTab() {
selectedHistory.error_msg && (
<div className='space-y-1.5'>
<span className='font-semibold text-destructive block'>
失败原因
{t('failureReason')}
</span>
<div className='font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all'>
{selectedHistory.error_msg}
@@ -383,7 +403,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知标题
{t('notificationTitle')}
</span>
<div className='bg-muted/30 p-2.5 rounded border font-medium text-[13px]'>
{selectedHistory.title}
@@ -392,7 +412,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知内容
{t('notificationContent')}
</span>
<div className='bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs'>
{selectedHistory.content}
@@ -408,7 +428,7 @@ export function HistoriesTab() {
onClick={() => setDetailOpen(false)}
className='h-9 text-xs'
>
关闭
{t('close')}
</Button>
</DialogFooter>
</DialogContent>
@@ -8,6 +8,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Edit2, Loader2, Play, Plus, Settings, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -51,6 +52,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function SettingsTab() {
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient();
// --- 获取所有自定义消息通道 ---
@@ -69,12 +71,12 @@ export function SettingsTab() {
const createChannelMutation = useMutation({
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
onSuccess: () => {
toast.success('通道创建成功');
toast.success(t('channelCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false);
},
onError: (err: unknown) => {
toast.error('通道创建失败: ' + (err as Error).message);
toast.error(t('channelCreateFailed') + ': ' + (err as Error).message);
},
});
@@ -82,23 +84,23 @@ export function SettingsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
PushService.updateChannel(id, data),
onSuccess: () => {
toast.success('通道更新成功');
toast.success(t('channelUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false);
},
onError: (err: unknown) => {
toast.error('通道更新失败: ' + (err as Error).message);
toast.error(t('channelUpdateFailed') + ': ' + (err as Error).message);
},
});
const deleteChannelMutation = useMutation({
mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => {
toast.success('通道删除成功');
toast.success(t('channelDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
},
onError: (err: unknown) => {
toast.error('通道删除失败: ' + (err as Error).message);
toast.error(t('channelDeleteFailed') + ': ' + (err as Error).message);
},
});
@@ -181,16 +183,16 @@ export function SettingsTab() {
const handleSaveChannel = () => {
if (!channelName && !editingChannel) {
toast.error('通道名称不能为空');
toast.error(t('channelNameRequired'));
return;
}
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
toast.error('通道名称只能使用英文字母、数字和下划线');
toast.error(t('channelNameFormat'));
return;
}
if (!activeDef) {
toast.error('无效的通道类型');
toast.error(t('invalidChannelType'));
return;
}
@@ -211,7 +213,7 @@ export function SettingsTab() {
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
if (channelType !== 'email') {
if (channelUrl && !channelUrl.startsWith('https://')) {
toast.error('地址必须以 https:// 开头以确保安全性');
toast.error(t('httpsRequired'));
return;
}
}
@@ -221,14 +223,14 @@ export function SettingsTab() {
try {
JSON.parse(channelOther);
} catch {
toast.error('请求体必须是合法的 JSON 格式');
toast.error(t('jsonFormatRequired'));
return;
}
} else if (channelType === 'lark' && channelOther) {
try {
JSON.parse(channelOther);
} catch {
toast.error('自定义卡片模版必须是合法的 JSON 格式');
toast.error(t('larkTemplateFormat'));
return;
}
}
@@ -267,15 +269,15 @@ export function SettingsTab() {
const handleSendChannelTest = async () => {
try {
setIsTestingChannel(true);
toast.info('正在发送测试推送...');
toast.info(t('sendingTestPush'));
await PushService.testChannel({
name: testChannelName,
target: testChannelTarget || undefined,
});
toast.success('测试推送发送成功,请前往对应平台确认。');
toast.success(t('testPushSuccess'));
setTestChannelOpen(false);
} catch (err: unknown) {
toast.error('连通性测试失败: ' + (err as Error).message);
toast.error(t('connectivityTestFailed') + ': ' + (err as Error).message);
} finally {
setIsTestingChannel(false);
}
@@ -285,9 +287,9 @@ export function SettingsTab() {
<div className='pt-4 space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h2 className='text-sm font-semibold'>自定义推送通道</h2>
<h2 className='text-sm font-semibold'>{t('customPushChannels')}</h2>
<p className='text-[11px] text-muted-foreground mt-0.5'>
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
{t('customPushChannelsDesc')}
</p>
</div>
<Button
@@ -296,14 +298,14 @@ export function SettingsTab() {
className='text-xs'
>
<Plus className='size-3.5 mr-1' />
新建消息通道
{t('createChannel')}
</Button>
</div>
{channelsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Settings}
description='加载消息通道中...'
description={t('loadingChannels')}
/>
) : channelsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'>
@@ -319,9 +321,7 @@ export function SettingsTab() {
className='size-8 mb-2 opacity-30 animate-spin'
style={{ animationDuration: '3s' }}
/>
<span className='text-xs font-medium'>
暂无自定义通道配置,请点击右上角新建
</span>
<span className='text-xs font-medium'>{t('noCustomChannels')}</span>
</div>
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
@@ -329,19 +329,19 @@ export function SettingsTab() {
<TableHeader className='sticky top-0 z-20 bg-background'>
<TableRow className='border-b border-dashed hover:bg-transparent'>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
名称
{t('colName')}
</TableHead>
<TableHead className='w-[100px] whitespace-nowrap py-2 h-8'>
类型
{t('colType')}
</TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'>
备注
{t('colRemark')}
</TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态
{t('colStatus')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -366,7 +366,9 @@ export function SettingsTab() {
</Badge>
</TableCell>
<TableCell className='text-xs text-muted-foreground max-w-[200px] truncate py-1'>
{ch.description || <span className='italic'>无备注</span>}
{ch.description || (
<span className='italic'>{t('noRemark')}</span>
)}
</TableCell>
<TableCell
className='text-center py-1'
@@ -402,7 +404,7 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10'
>
<Play className='size-2.5 mr-1' />
测试
{t('test')}
</Button>
<Button
variant='ghost'
@@ -411,21 +413,25 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground'
>
<Edit2 className='size-2.5 mr-1' />
编辑
{t('edit')}
</Button>
<Button
variant='ghost'
size='sm'
disabled={deleteChannelMutation.isPending}
onClick={() => {
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
if (
confirm(
t('deleteChannelConfirm', { name: ch.name }),
)
) {
deleteChannelMutation.mutate(ch.id);
}
}}
className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
>
<Trash2 className='size-2.5 mr-1' />
删除
{t('delete')}
</Button>
</div>
</TableCell>
@@ -441,35 +447,38 @@ export function SettingsTab() {
<DialogContent className='sm:max-w-[600px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{editingChannel ? '编辑消息通道' : '新建消息通道'}
{editingChannel
? t('editChannelDialogTitle')
: t('createChannelDialogTitle')}
</DialogTitle>
<DialogDescription>
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook
或推送服务投递数据
</DialogDescription>
<DialogDescription>{t('channelDialogDesc')}</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>名称</Label>
<Label className='text-xs font-semibold'>
{t('channelName')}
</Label>
<Input
type='text'
placeholder='请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一'
placeholder={t('channelNamePlaceholder')}
value={channelName}
onChange={(e) => setChannelName(e.target.value)}
disabled={!!editingChannel}
className='text-xs h-9 font-mono'
/>
<p className='text-[10px] text-muted-foreground'>
通道唯一标识,创建后不可修改
{t('channelNameHint')}
</p>
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>备注</Label>
<Label className='text-xs font-semibold'>
{t('channelRemark')}
</Label>
<Input
type='text'
placeholder='请输入备注信息'
placeholder={t('channelRemarkPlaceholder')}
value={channelDescription}
onChange={(e) => setChannelDescription(e.target.value)}
className='text-xs h-9'
@@ -477,13 +486,15 @@ export function SettingsTab() {
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>通道类型</Label>
<Label className='text-xs font-semibold'>
{t('channelType')}
</Label>
<Select
value={channelType}
onValueChange={handleChannelTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='选择通道类型' />
<SelectValue placeholder={t('selectChannelType')} />
</SelectTrigger>
<SelectContent>
{(definitionsQuery.data ?? []).map((d) => (
@@ -562,7 +573,7 @@ export function SettingsTab() {
{channelType === 'custom' && (
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-2.5'>
<Label className='text-[11px] font-semibold'>
快捷加载常用模版实例:
{t('quickLoadTemplates')}
</Label>
<div className='flex flex-wrap gap-1.5'>
<Button
@@ -586,7 +597,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载飞书 Webhook 模版');
toast.success(t('loadedFeishuTemplate'));
}}
>
飞书 Webhook
@@ -613,7 +624,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载钉钉机器人模版');
toast.success(t('loadedDingTalkTemplate'));
}}
>
钉钉群机器人
@@ -640,7 +651,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载企业微信机器人模版');
toast.success(t('loadedWeChatTemplate'));
}}
>
企业微信群机器人
@@ -664,7 +675,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载 Bark App 模版');
toast.success(t('loadedBarkTemplate'));
}}
>
Bark App
@@ -683,7 +694,7 @@ export function SettingsTab() {
onClick={() => setChannelDialogOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -699,7 +710,7 @@ export function SettingsTab() {
updateChannelMutation.isPending) && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
确定
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -709,15 +720,15 @@ export function SettingsTab() {
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
<DialogContent className='sm:max-w-[450px]'>
<DialogHeader>
<DialogTitle>发送测试通知</DialogTitle>
<DialogTitle>{t('sendTestNotification')}</DialogTitle>
<DialogDescription>
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
{t('sendTestNotificationDesc')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-3'>
<div className='space-y-1.5'>
<Label className='text-xs'>推送通道名称</Label>
<Label className='text-xs'>{t('pushChannelName')}</Label>
<Input
type='text'
value={testChannelName}
@@ -726,10 +737,10 @@ export function SettingsTab() {
/>
</div>
<div className='space-y-1.5'>
<Label className='text-xs'>测试推送目标 (对应模板变量 $to)</Label>
<Label className='text-xs'>{t('testPushTarget')}</Label>
<Input
type='text'
placeholder='请输入测试推送接收人/目标标识,如 Bark Token、邮箱等'
placeholder={t('testPushTargetPlaceholder')}
value={testChannelTarget}
onChange={(e) => setTestChannelTarget(e.target.value)}
className='text-xs h-9'
@@ -744,7 +755,7 @@ export function SettingsTab() {
onClick={() => setTestChannelOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -756,7 +767,7 @@ export function SettingsTab() {
{isTestingChannel && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
发送测试
{t('sendTest')}
</Button>
</DialogFooter>
</DialogContent>
+6 -4
View File
@@ -6,6 +6,7 @@
import * as React from 'react';
import { motion } from 'motion/react';
import { Bell, History, Layers, Settings } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { EventsTab } from './components/events-tab';
@@ -13,6 +14,7 @@ import { HistoriesTab } from './components/histories-tab';
import { SettingsTab } from './components/settings-tab';
export default function PushAdminPage() {
const t = useTranslations('admin.push');
const [activeTab, setActiveTab] = React.useState('events');
return (
@@ -26,7 +28,7 @@ export default function PushAdminPage() {
<Bell className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
通知推送管理
{t('pageTitle')}
</h1>
</div>
</div>
@@ -38,21 +40,21 @@ export default function PushAdminPage() {
className='px-0 pb-2 text-xs font-semibold'
>
<Layers className='size-3.5 mr-1' />
事件管理
{t('eventsTab')}
</TabsTrigger>
<TabsTrigger
value='histories'
className='px-0 pb-2 text-xs font-semibold'
>
<History className='size-3.5 mr-1' />
通知历史
{t('historiesTab')}
</TabsTrigger>
<TabsTrigger
value='settings'
className='px-0 pb-2 text-xs font-semibold'
>
<Settings className='size-3.5 mr-1' />
通道管理与设置
{t('settingsTab')}
</TabsTrigger>
</TabsList>
@@ -2,6 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
Activity,
@@ -45,6 +46,7 @@ const formatNumber = (num: number | string) => {
* 运行时系统状态展示与管理组件
*/
export function SystemStatusManager() {
const t = useTranslations('admin.settings.status');
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [wavelet, setWavelet] = useState(false);
@@ -58,38 +60,42 @@ export function SystemStatusManager() {
);
// 获取状态数据
const fetchStatus = useCallback(async (isSilent = false) => {
if (!isSilent) setWavelet(true);
try {
const data = await services.adminStatus.getSystemStatus();
const fetchStatus = useCallback(
async (isSilent = false) => {
if (!isSilent) setWavelet(true);
try {
const data = await services.adminStatus.getSystemStatus();
// 检测变化的字段用于微动画效果
if (prevStatusRef.current) {
const changes: Record<string, boolean> = {};
Object.keys(data).forEach((key) => {
const k = key as keyof SystemStatus;
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
changes[k] = true;
}
// 检测变化的字段用于微动画效果
if (prevStatusRef.current) {
const changes: Record<string, boolean> = {};
Object.keys(data).forEach((key) => {
const k = key as keyof SystemStatus;
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
changes[k] = true;
}
});
setChangedFields(changes);
// 1秒后清除变化状态
setTimeout(() => {
setChangedFields({});
}, 1000);
}
setStatus(data);
prevStatusRef.current = data;
} catch (err) {
toast.error(t('fetchFailed'), {
description:
err instanceof Error ? err.message : t('unknownRequestError'),
});
setChangedFields(changes);
// 1秒后清除变化状态
setTimeout(() => {
setChangedFields({});
}, 1000);
} finally {
setLoading(false);
setWavelet(false);
}
setStatus(data);
prevStatusRef.current = data;
} catch (err) {
toast.error('获取系统状态失败', {
description: err instanceof Error ? err.message : '请求时发生未知错误',
});
} finally {
setLoading(false);
setWavelet(false);
}
}, []);
},
[t],
);
// 轮询逻辑
useEffect(() => {
@@ -196,7 +202,9 @@ export function SystemStatusManager() {
<div className='flex items-center gap-2'>
<Activity 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>
@@ -211,7 +219,7 @@ export function SystemStatusManager() {
htmlFor='auto-refresh'
className='text-xs font-medium cursor-pointer select-none'
>
自动刷新
{t('autoRefresh')}
</label>
</div>
@@ -237,7 +245,7 @@ export function SystemStatusManager() {
disabled={wavelet}
>
<RefreshCw className={`size-3 ${wavelet ? 'animate-spin' : ''}`} />
刷新
{t('refresh')}
</Button>
</div>
</div>
@@ -251,10 +259,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
服务概览
{t('cards.serviceOverview')}
</CardTitle>
<CardDescription className='text-[10px]'>
服务的基础生命指标
{t('cards.serviceOverviewDesc')}
</CardDescription>
</div>
<Badge
@@ -266,17 +274,17 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='服务运行时间'
label={t('metrics.uptime')}
value={status.uptime}
field='uptime'
/>
<RenderMetricRow
label='当前 Goroutines 数量'
label={t('metrics.goroutines')}
value={status.num_goroutine}
field='num_goroutine'
/>
<RenderMetricRow
label='Heap 对象数量'
label={t('metrics.heapObjects')}
value={status.heap_objects}
field='heap_objects'
/>
@@ -288,10 +296,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
内存统计
{t('cards.memoryStats')}
</CardTitle>
<CardDescription className='text-[10px]'>
主内存消耗概览
{t('cards.memoryStatsDesc')}
</CardDescription>
</div>
<Badge
@@ -304,22 +312,22 @@ export function SystemStatusManager() {
<CardContent className='space-y-3'>
<div className='space-y-1'>
<RenderMetricRow
label='当前内存使用量'
label={t('metrics.alloc')}
value={status.alloc}
field='alloc'
/>
<RenderMetricRow
label='所有已分配的内存'
label={t('metrics.totalAlloc')}
value={status.total_alloc}
field='total_alloc'
/>
<RenderMetricRow
label='内存占用量'
label={t('metrics.sys')}
value={status.sys}
field='sys'
/>
<RenderMetricRow
label='下次 GC 内存回收量'
label={t('metrics.nextGc')}
value={status.next_gc}
field='next_gc'
/>
@@ -327,7 +335,7 @@ export function SystemStatusManager() {
{/* 物理内存水位比例 */}
<div className='space-y-1 px-1 pt-1'>
<div className='flex justify-between text-[10px] text-muted-foreground'>
<span>当前使用率 (Alloc / Sys)</span>
<span>{t('metrics.memoryUsagePercent')}</span>
<span className='font-mono'>
{getMemoryUsagePercent()}%
</span>
@@ -342,10 +350,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
堆/栈详情
{t('cards.heapStack')}
</CardTitle>
<CardDescription className='text-[10px]'>
运行时堆栈内存空间细节
{t('cards.heapStackDesc')}
</CardDescription>
</div>
<Badge
@@ -357,37 +365,37 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='当前 Heap 内存使用量'
label={t('metrics.heapAlloc')}
value={status.heap_alloc}
field='heap_alloc'
/>
<RenderMetricRow
label='Heap 内存占用量'
label={t('metrics.heapSys')}
value={status.heap_sys}
field='heap_sys'
/>
<RenderMetricRow
label='Heap 内存空闲量'
label={t('metrics.heapIdle')}
value={status.heap_idle}
field='heap_idle'
/>
<RenderMetricRow
label='正在使用的 Heap 内存'
label={t('metrics.heapInuse')}
value={status.heap_inuse}
field='heap_inuse'
/>
<RenderMetricRow
label='已释放的 Heap 内存'
label={t('metrics.heapReleased')}
value={status.heap_released}
field='heap_released'
/>
<RenderMetricRow
label='启动 Stack 使用量'
label={t('metrics.stackInuse')}
value={status.stack_inuse}
field='stack_inuse'
/>
<RenderMetricRow
label='已分配的 Stack 内存'
label={t('metrics.stackSys')}
value={status.stack_sys}
field='stack_sys'
/>
@@ -399,10 +407,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
底层及结构体内存
{t('cards.internalStructs')}
</CardTitle>
<CardDescription className='text-[10px]'>
运行时底层管理结构体开销
{t('cards.internalStructsDesc')}
</CardDescription>
</div>
<Badge
@@ -414,37 +422,37 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='MSpan 结构内存使用量'
label={t('metrics.mspanInuse')}
value={status.mspan_inuse}
field='mspan_inuse'
/>
<RenderMetricRow
label='已分配的 MSpan 结构内存'
label={t('metrics.mspanSys')}
value={status.mspan_sys}
field='mspan_sys'
/>
<RenderMetricRow
label='MCache 结构内存使用量'
label={t('metrics.mcacheInuse')}
value={status.mcache_inuse}
field='mcache_inuse'
/>
<RenderMetricRow
label='已分配的 MCache 结构内存'
label={t('metrics.mcacheSys')}
value={status.mcache_sys}
field='mcache_sys'
/>
<RenderMetricRow
label='已分配的剖析哈希表内存'
label={t('metrics.buckHashSys')}
value={status.buck_hash_sys}
field='buck_hash_sys'
/>
<RenderMetricRow
label='已分配的 GC 元数据内存'
label={t('metrics.gcSys')}
value={status.gc_sys}
field='gc_sys'
/>
<RenderMetricRow
label='其它已分配的系统内存'
label={t('metrics.otherSys')}
value={status.other_sys}
field='other_sys'
/>
@@ -456,10 +464,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
垃圾回收与分配计数
{t('cards.gcAndAlloc')}
</CardTitle>
<CardDescription className='text-[10px]'>
GC 历史数据与分配频次
{t('cards.gcAndAllocDesc')}
</CardDescription>
</div>
<Badge
@@ -473,39 +481,39 @@ export function SystemStatusManager() {
<div className='grid grid-cols-1 sm:grid-cols-2 gap-x-6'>
<div>
<RenderMetricRow
label='GC 执行次数'
label={t('metrics.numGc')}
value={status.num_gc}
field='num_gc'
/>
<RenderMetricRow
label='距离上次 GC 时间'
label={t('metrics.lastGcTime')}
value={status.last_gc_time}
field='last_gc_time'
/>
<RenderMetricRow
label='GC 暂停时间总量'
label={t('metrics.pauseTotalNs')}
value={status.pause_total_ns}
field='pause_total_ns'
/>
<RenderMetricRow
label='上次 GC 暂停时间'
label={t('metrics.lastPause')}
value={status.last_pause}
field='last_pause'
/>
</div>
<div>
<RenderMetricRow
label='内存分配次数'
label={t('metrics.mallocs')}
value={status.mallocs}
field='mallocs'
/>
<RenderMetricRow
label='内存释放次数'
label={t('metrics.frees')}
value={status.frees}
field='frees'
/>
<RenderMetricRow
label='指针查找次数'
label={t('metrics.lookups')}
value={status.lookups}
field='lookups'
/>
@@ -5,6 +5,7 @@
import dynamic from 'next/dynamic';
import { useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query';
import { Loader2, Settings } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
@@ -71,6 +72,7 @@ function systemConfigMap(configs: SystemConfig[]) {
}
export function AdminSettingsPageClient() {
const t = useTranslations('admin.settings');
const { user, loading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
@@ -127,7 +129,9 @@ export function AdminSettingsPageClient() {
<div className='flex items-center gap-2'>
<Settings 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>
<Tabs
@@ -140,34 +144,34 @@ export function AdminSettingsPageClient() {
value='security'
className='px-0 pb-2 text-xs font-semibold'
>
安全设置
{t('tabs.security')}
</TabsTrigger>
<TabsTrigger
value='operation'
className='px-0 pb-2 text-xs font-semibold'
>
业务设置
{t('tabs.operation')}
</TabsTrigger>
<TabsTrigger
value='system'
className='px-0 pb-2 text-xs font-semibold'
>
系统设置
{t('tabs.system')}
</TabsTrigger>
<TabsTrigger
value='other'
className='px-0 pb-2 text-xs font-semibold'
>
其他设置
{t('tabs.other')}
</TabsTrigger>
<TabsTrigger
value='status'
className='px-0 pb-2 text-xs font-semibold'
>
系统状态
{t('tabs.status')}
</TabsTrigger>
<TabsTrigger value='info' className='px-0 pb-2 text-xs font-semibold'>
系统信息
{t('tabs.info')}
</TabsTrigger>
</TabsList>
+26 -19
View File
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
@@ -51,13 +52,14 @@ function SystemConfigDetailPanel({
onSave: () => void;
saving: boolean;
}) {
const t = useTranslations('admin.system');
return (
<ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}>
<div className='grid grid-cols-1 gap-0'>
<div className='border border-dashed rounded-lg'>
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置键
{t('configKey')}
</label>
<p className='text-xs text-muted-foreground font-mono'>
{config?.key}
@@ -66,7 +68,7 @@ function SystemConfigDetailPanel({
<div className='pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置值
{t('configValue')}
</label>
<div className='flex gap-1 w-[90%] justify-end items-center pr-3'>
{config?.key.endsWith('_enabled') ? (
@@ -107,7 +109,9 @@ function SystemConfigDetailPanel({
: config?.value || ''
}
placeholder={
editData.value === undefined && !config?.value ? '必需' : ''
editData.value === undefined && !config?.value
? t('required')
: ''
}
onChange={(e) => {
const value = e.target.value;
@@ -128,7 +132,7 @@ function SystemConfigDetailPanel({
<div className='pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置描述
{t('configDescription')}
</label>
<div className='flex gap-1 w-[90%]'>
<Input
@@ -138,7 +142,7 @@ function SystemConfigDetailPanel({
? editData.description
: config?.description || ''
}
placeholder='可选描述'
placeholder={t('optionalDescription')}
onChange={(e) => {
const value = e.target.value;
onEditDataChange('description', value);
@@ -150,7 +154,7 @@ function SystemConfigDetailPanel({
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
公共可见
{t('publicVisibility')}
</label>
<Switch
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
@@ -162,7 +166,7 @@ function SystemConfigDetailPanel({
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置类型
{t('configType')}
</label>
<span
className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
@@ -171,13 +175,15 @@ function SystemConfigDetailPanel({
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
}`}
>
{config?.type === 'system' ? '系统配置' : '业务配置'}
{config?.type === 'system'
? t('systemConfig')
: t('businessConfig')}
</span>
</div>
<div className='px-3 py-2 flex items-center justify-between'>
<label className='text-xs font-medium text-muted-foreground'>
创建时间
{t('createdAt')}
</label>
<p className='text-xs text-muted-foreground'>
{config ? formatDateTime(config.created_at) : ''}
@@ -199,6 +205,7 @@ function SystemConfigDetailPanel({
* @returns {React.ReactNode} 系统配置管理组件
*/
export function SystemConfigs() {
const t = useTranslations('admin.system');
const {
systemConfigs: configs,
systemConfigsLoading: loading,
@@ -236,7 +243,7 @@ export function SystemConfigs() {
return (
<ManagePage<SystemConfig>
title='系统配置'
title={t('pageTitle')}
icon={ShieldCheck}
data={configs}
loading={loading}
@@ -245,8 +252,8 @@ export function SystemConfigs() {
getInitialEditData={getInitialEditData}
onSave={handleSave}
getId={(config) => config.key}
emptyDescription='未发现系统配置'
loadingDescription='配置加载中'
emptyDescription={t('emptyDescription')}
loadingDescription={t('loadingDescription')}
headerExtra={
<Tabs
value={activeTab}
@@ -255,24 +262,24 @@ export function SystemConfigs() {
>
<TabsList className='grid w-full grid-cols-2 h-8'>
<TabsTrigger value='business' className='text-[11px] h-7'>
业务配置
{t('businessConfig')}
</TabsTrigger>
<TabsTrigger value='system' className='text-[11px] h-7'>
系统配置
{t('systemConfig')}
</TabsTrigger>
</TabsList>
</Tabs>
}
columns={[
{
header: '配置键',
header: t('configKey'),
cell: (item) => (
<span className='font-mono font-medium'>{item.key}</span>
),
width: '200px',
},
{
header: '配置值',
header: t('configValue'),
cell: (item) => (
<span
className='truncate max-w-[120px] inline-block'
@@ -284,14 +291,14 @@ export function SystemConfigs() {
width: '120px',
},
{
header: '公共可见',
header: t('publicVisibility'),
cell: (item) => (
<span>{item.visibility === 1 ? '可见' : '不可见'}</span>
<span>{item.visibility === 1 ? t('visible') : t('invisible')}</span>
),
width: '80px',
},
{
header: '描述',
header: t('description'),
cell: (item) => (
<span
className='truncate max-w-[200px] inline-block text-muted-foreground'
@@ -1,6 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format } from 'date-fns';
@@ -49,18 +50,18 @@ import {
TableRow,
} from '@/components/ui/table';
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: '等待中',
running: '执行中',
succeeded: '成功',
failed: '失败',
const STATUS_LABELS_KEYS: Record<TaskExecutionStatus, string> = {
pending: 'statusPending',
running: 'statusRunning',
succeeded: 'statusSucceeded',
failed: 'statusFailed',
};
const TRIGGER_LABELS: Record<string, string> = {
system: '系统',
manual: '手动',
retry: '重试',
schedule: '定时',
const TRIGGER_LABELS_KEYS: Record<string, string> = {
system: 'triggerSystem',
manual: 'triggerManual',
retry: 'triggerRetry',
schedule: 'triggerSchedule',
};
const PAGE_SIZE = 10;
@@ -85,6 +86,7 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function TaskExecutionsManager() {
const t = useTranslations('admin.tasks');
const queryClient = useQueryClient();
const [executionsPage, setExecutionsPage] = useState(1);
const [executionStatus, setExecutionStatus] = useState<
@@ -129,16 +131,16 @@ export function TaskExecutionsManager() {
const retryMutation = useMutation({
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success('任务已重新下发', {
description: `新任务 ID:${taskID}`,
toast.success(t('retrySuccess'), {
description: t('retrySuccessDesc', { taskId: taskID }),
});
void queryClient.invalidateQueries({
queryKey: ['admin', 'task-executions'],
});
},
onError: (err: Error) => {
toast.error('任务重试失败', {
description: err.message || '未知错误',
toast.error(t('retryFailed'), {
description: err.message || t('unknownError'),
});
},
});
@@ -190,11 +192,11 @@ export function TaskExecutionsManager() {
<SelectValue placeholder='状态' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部状态</SelectItem>
<SelectItem value='pending'>等待中</SelectItem>
<SelectItem value='running'>执行中</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem>
<SelectItem value='failed'>失败</SelectItem>
<SelectItem value='all'>{t('allStatuses')}</SelectItem>
<SelectItem value='pending'>{t('statusPending')}</SelectItem>
<SelectItem value='running'>{t('statusRunning')}</SelectItem>
<SelectItem value='succeeded'>{t('statusSucceeded')}</SelectItem>
<SelectItem value='failed'>{t('statusFailed')}</SelectItem>
</SelectContent>
</Select>
<Select
@@ -205,7 +207,7 @@ export function TaskExecutionsManager() {
<SelectValue placeholder='任务类型' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部任务</SelectItem>
<SelectItem value='all'>{t('allTasks')}</SelectItem>
{taskTypes.map((task) => (
<SelectItem key={task.type} value={task.type}>
{task.name || task.type}
@@ -224,7 +226,7 @@ export function TaskExecutionsManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新
{t('refresh')}
</Button>
</div>
</div>
@@ -240,22 +242,24 @@ export function TaskExecutionsManager() {
) : executionsLoading && executions.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务执行记录中...'
description={t('loadingExecutions')}
/>
) : executions.length === 0 ? (
<EmptyStateWithBorder icon={Activity} description='暂无任务执行记录' />
<EmptyStateWithBorder icon={Activity} description={t('noExecutions')} />
) : (
<div className='rounded-lg border bg-card'>
<Table className='min-w-[900px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务</TableHead>
<TableHead className='w-[100px]'>状态</TableHead>
<TableHead className='w-[110px]'>触发</TableHead>
<TableHead className='w-[120px]'>重试</TableHead>
<TableHead className='w-[120px]'>耗时</TableHead>
<TableHead className='min-w-[220px]'>结果/错误</TableHead>
<TableHead className='w-[170px]'>创建时间</TableHead>
<TableHead className='w-[180px]'>{t('colTask')}</TableHead>
<TableHead className='w-[100px]'>{t('colStatus')}</TableHead>
<TableHead className='w-[110px]'>{t('colTrigger')}</TableHead>
<TableHead className='w-[120px]'>{t('colRetry')}</TableHead>
<TableHead className='w-[120px]'>{t('colDuration')}</TableHead>
<TableHead className='min-w-[220px]'>
{t('colResult')}
</TableHead>
<TableHead className='w-[170px]'>{t('colCreatedAt')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -277,12 +281,13 @@ export function TaskExecutionsManager() {
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
{t(STATUS_LABELS_KEYS[execution.status]) ||
execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] ||
{t(TRIGGER_LABELS_KEYS[execution.triggered_by]) ||
execution.triggered_by}
</Badge>
</TableCell>
@@ -307,7 +312,11 @@ export function TaskExecutionsManager() {
<div className='flex items-center justify-between'>
<div className='text-xs text-muted-foreground'>
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
{t('paginationInfo', {
total: executionsTotal,
current: executionsPage,
totalPages,
})}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -317,7 +326,7 @@ export function TaskExecutionsManager() {
disabled={executionsPage <= 1 || executionsLoading}
>
<ChevronLeft className='size-4' />
上一页
{t('prevPage')}
</Button>
<Button
variant='outline'
@@ -327,7 +336,7 @@ export function TaskExecutionsManager() {
}
disabled={executionsPage >= totalPages || executionsLoading}
>
下一页
{t('nextPage')}
<ChevronRight className='size-4' />
</Button>
</div>
@@ -336,7 +345,7 @@ export function TaskExecutionsManager() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[640px]'>
<SheetHeader className='border-b'>
<SheetTitle>任务执行详情</SheetTitle>
<SheetTitle>{t('executionDetailTitle')}</SheetTitle>
<SheetDescription>
{selectedExecution?.task_name ||
selectedExecution?.task_type ||
@@ -348,32 +357,34 @@ export function TaskExecutionsManager() {
{detailLoading && !selectedExecution ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务详情中...'
description={t('loadingDetail')}
/>
) : selectedExecution ? (
<div className='space-y-5 py-4'>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>状态</div>
<div className='text-xs text-muted-foreground'>
{t('detailStatus')}
</div>
<div className='mt-2'>
<Badge variant={statusVariant(selectedExecution.status)}>
{STATUS_LABELS[selectedExecution.status] ||
{t(STATUS_LABELS_KEYS[selectedExecution.status]) ||
selectedExecution.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
触发来源
{t('detailTrigger')}
</div>
<div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selectedExecution.triggered_by] ||
{t(TRIGGER_LABELS_KEYS[selectedExecution.triggered_by]) ||
selectedExecution.triggered_by}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
重试次数
{t('detailRetryCount')}
</div>
<div className='mt-2 font-mono text-sm'>
{selectedExecution.retry_count}/
@@ -381,7 +392,9 @@ export function TaskExecutionsManager() {
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>耗时</div>
<div className='text-xs text-muted-foreground'>
{t('detailDuration')}
</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selectedExecution.duration)}
</div>
@@ -389,7 +402,7 @@ export function TaskExecutionsManager() {
</div>
<div className='grid gap-2'>
<Label>任务标识</Label>
<Label>{t('detailTaskId')}</Label>
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selectedExecution.task_id}
</div>
@@ -397,25 +410,25 @@ export function TaskExecutionsManager() {
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<div className='grid gap-2'>
<Label>创建时间</Label>
<Label>{t('detailCreatedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.created_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>开始时间</Label>
<Label>{t('detailStartedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.started_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>结束时间</Label>
<Label>{t('detailFinishedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.finished_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>更新时间</Label>
<Label>{t('detailUpdatedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.updated_at)}
</div>
@@ -423,7 +436,7 @@ export function TaskExecutionsManager() {
</div>
<div className='grid gap-2'>
<Label>执行结果</Label>
<Label>{t('detailResult')}</Label>
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selectedExecution.result || '-'}
</div>
@@ -431,7 +444,7 @@ export function TaskExecutionsManager() {
{selectedExecution.error_message && (
<div className='grid gap-2'>
<Label>错误信息</Label>
<Label>{t('detailError')}</Label>
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
{selectedExecution.error_message}
</div>
@@ -439,23 +452,23 @@ export function TaskExecutionsManager() {
)}
<div className='grid gap-2'>
<Label>Payload</Label>
<Label>{t('detailPayload')}</Label>
<pre className='max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selectedExecution.payload || '{}'}
</pre>
</div>
<div className='grid gap-2'>
<Label>执行日志</Label>
<Label>{t('detailLog')}</Label>
<pre className='min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap'>
{selectedExecution.log || '暂无日志'}
{selectedExecution.log || t('noLog')}
</pre>
</div>
</div>
) : (
<EmptyStateWithBorder
icon={Activity}
description='未选择任务记录'
description={t('noExecutionSelected')}
/>
)}
</div>
@@ -471,7 +484,7 @@ export function TaskExecutionsManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新详情
{t('refreshDetail')}
</Button>
{selectedExecution &&
selectedExecution.status === 'failed' &&
@@ -485,7 +498,7 @@ export function TaskExecutionsManager() {
) : (
<RotateCcw className='size-4' />
)}
重试任务
{t('retryTask')}
</Button>
)}
</SheetFooter>
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -166,6 +167,7 @@ function DatePickerWithTime({
}
export function TaskManager() {
const t = useTranslations('admin.tasks');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([]);
@@ -186,11 +188,13 @@ export function TaskManager() {
const data = await services.adminTask.getTaskTypes();
setTaskTypes(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败'));
setError(
err instanceof Error ? err : new Error(t('loadTaskTypesFailed')),
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchTaskTypes();
@@ -249,8 +253,11 @@ export function TaskManager() {
const taskID = await services.adminTask.dispatchTask(params);
toast.success('任务下发成功', {
description: `已成功将任务 ${targetTask?.name || selectedTaskType} 加入队列:${taskID}`,
toast.success(t('dispatchSuccess'), {
description: t('dispatchSuccessDesc', {
name: targetTask?.name || selectedTaskType,
taskId: taskID,
}),
});
setDialogOpen(false);
@@ -259,8 +266,8 @@ export function TaskManager() {
setUserId('');
setParamValues({});
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('dispatchFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setDispatching(false);
@@ -292,10 +299,10 @@ export function TaskManager() {
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder
icon={Layers}
description='加载任务类型中...'
description={t('loadingTaskTypes')}
/>
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description='暂无可用任务类型' />
<EmptyStateWithBorder icon={Layers} description={t('noTaskTypes')} />
) : (
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'>
{taskTypes.map((task, index) => {
@@ -333,13 +340,15 @@ export function TaskManager() {
variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
>
类型:{task.type}
{t('taskType')}
{task.type}
</Badge>
<Badge
variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
>
重试:{task.max_retry}
{t('taskRetry')}
{task.max_retry}
</Badge>
</div>
</div>
@@ -351,7 +360,7 @@ export function TaskManager() {
onClick={() => openDispatchDialog(task.type)}
>
<Play className='size-3 mr-1' />
立即执行
{t('executeNow')}
</Button>
</div>
</div>
@@ -365,15 +374,13 @@ export function TaskManager() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
<DialogTitle>{t('dispatchTask')}</DialogTitle>
<DialogDescription>{t('dispatchTaskDesc')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label>执行任务</Label>
<Label>{t('executeTask')}</Label>
<div className='flex items-center gap-2 p-2 rounded-lg bg-muted/50'>
{(() => {
const meta = getSelectedTaskMeta();
@@ -403,20 +410,20 @@ export function TaskManager() {
return (
<>
<div className='grid gap-2'>
<Label>开始时间</Label>
<Label>{t('startTime')}</Label>
<DatePickerWithTime
date={startTime}
setDate={setStartTime}
/>
<p className='text-xs text-muted-foreground'>
选择同步的起始时间点
{t('startTimeDesc')}
</p>
</div>
<div className='grid gap-2'>
<Label>结束时间</Label>
<Label>{t('endTime')}</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className='text-xs text-muted-foreground'>
选择同步的结束时间点
{t('endTimeDesc')}
</p>
</div>
</>
@@ -474,8 +481,8 @@ export function TaskManager() {
/>
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? '开启'
: '关闭'}
? t('paramOn')
: t('paramOff')}
</span>
</div>
) : (
@@ -513,7 +520,7 @@ export function TaskManager() {
return (
<div className='flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed'>
<Info className='h-4 w-4' />
<span>此任务无需额外配置参数,确认后将直接下发。</span>
<span>{t('noParamsHint')}</span>
</div>
);
}
@@ -528,7 +535,7 @@ export function TaskManager() {
disabled={dispatching}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
onClick={handleDispatch}
@@ -540,7 +547,7 @@ export function TaskManager() {
) : (
<Play className='size-3' />
)}
{dispatching ? '下发中' : '启动'}
{dispatching ? t('dispatching') : t('start')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -39,6 +40,7 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
export function TaskSchedulesManager() {
const t = useTranslations('admin.tasks');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [schedules, setSchedules] = useState<Schedule[]>([]);
@@ -73,11 +75,13 @@ export function TaskSchedulesManager() {
setSchedules(schedulesData || []);
setTaskTypes(taskTypesData || []);
} catch (err) {
setError(err instanceof Error ? err : new Error('加载定时任务配置失败'));
setError(
err instanceof Error ? err : new Error(t('loadSchedulesFailed')),
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchData();
@@ -130,12 +134,12 @@ export function TaskSchedulesManager() {
);
toast.success(
updated.is_active
? `已启用定时任务:${updated.name}`
: `已停用定时任务:${updated.name}`,
? t('scheduleEnabled', { name: updated.name })
: t('scheduleDisabled', { name: updated.name }),
);
} catch (err) {
toast.error('修改任务状态失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('changeScheduleStatusFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
}
};
@@ -161,11 +165,11 @@ export function TaskSchedulesManager() {
const handleSubmit = async () => {
if (!name.trim()) {
toast.error('任务名称不能为空');
toast.error(t('scheduleNameRequired'));
return;
}
if (!cron.trim()) {
toast.error('Cron 表达式不能为空');
toast.error(t('cronRequired'));
return;
}
@@ -203,7 +207,7 @@ export function TaskSchedulesManager() {
setSchedules((prev) =>
prev.map((s) => (s.id === editingSchedule.id ? updated : s)),
);
toast.success('定时任务更新成功');
toast.success(t('scheduleUpdateSuccess'));
} else {
const req: CreateScheduleRequest = {
name,
@@ -214,13 +218,13 @@ export function TaskSchedulesManager() {
};
const created = await services.adminTask.createSchedule(req);
setSchedules((prev) => [created, ...prev]);
toast.success('定时任务创建成功');
toast.success(t('scheduleCreateSuccess'));
}
setDialogOpen(false);
} catch (err) {
toast.error('提交失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('submitFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setSubmitLoading(false);
@@ -238,11 +242,11 @@ export function TaskSchedulesManager() {
setDeleteLoading(true);
await services.adminTask.deleteSchedule(deletingId);
setSchedules((prev) => prev.filter((s) => s.id !== deletingId));
toast.success('定时任务删除成功');
toast.success(t('scheduleDeleteSuccess'));
setDeleteOpen(false);
} catch (err) {
toast.error('删除失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('deleteFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setDeleteLoading(false);
@@ -274,7 +278,7 @@ export function TaskSchedulesManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新
{t('refresh')}
</Button>
<Button
size='sm'
@@ -283,7 +287,7 @@ export function TaskSchedulesManager() {
variant={'secondary'}
>
<Plus className='size-4 mr-1' />
新增定时任务
{t('addSchedule')}
</Button>
</div>
</div>
@@ -299,28 +303,31 @@ export function TaskSchedulesManager() {
) : loading && schedules.length === 0 ? (
<LoadingStateWithBorder
icon={Clock}
description='加载定时任务配置中...'
description={t('loadingSchedules')}
/>
) : schedules.length === 0 ? (
<EmptyStateWithBorder
icon={Clock}
description='暂无定时任务配置,点击上方按钮新增'
/>
<EmptyStateWithBorder icon={Clock} description={t('noSchedules')} />
) : (
<div className='rounded-lg border bg-card'>
<Table className='min-w-[800px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务名称</TableHead>
<TableHead className='w-[160px]'>关联异步任务</TableHead>
<TableHead className='w-[120px]'>Cron 表达式</TableHead>
<TableHead className='w-[180px]'>
{t('colScheduleName')}
</TableHead>
<TableHead className='w-[160px]'>
{t('colAssociatedTask')}
</TableHead>
<TableHead className='w-[120px]'>{t('colCron')}</TableHead>
<TableHead className='min-w-[200px]'>
执行参数 (Payload)
{t('colPayload')}
</TableHead>
<TableHead className='w-[100px] text-center'>
启用状态
{t('colEnabled')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('colActions')}
</TableHead>
<TableHead className='w-[120px] text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -385,19 +392,17 @@ export function TaskSchedulesManager() {
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{editingSchedule ? '修改定时任务' : '新增定时任务'}
{editingSchedule ? t('editSchedule') : t('addSchedule')}
</DialogTitle>
<DialogDescription>
配置定时任务的调度参数和运行载荷。
</DialogDescription>
<DialogDescription>{t('scheduleDialogDesc')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='sched-name'>任务名称</Label>
<Label htmlFor='sched-name'>{t('scheduleName')}</Label>
<Input
id='sched-name'
placeholder='例如:每日数据清理'
placeholder={t('scheduleNamePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
className='text-xs'
@@ -405,7 +410,7 @@ export function TaskSchedulesManager() {
</div>
<div className='grid gap-2'>
<Label htmlFor='sched-type'>关联异步任务</Label>
<Label htmlFor='sched-type'>{t('associatedTask')}</Label>
<select
id='sched-type'
value={selectedTaskType}
@@ -422,24 +427,24 @@ export function TaskSchedulesManager() {
</div>
<div className='grid gap-2'>
<Label htmlFor='sched-cron'>Cron 表达式</Label>
<Label htmlFor='sched-cron'>{t('cronExpression')}</Label>
<Input
id='sched-cron'
placeholder='e.g. 0 */2 * * * 或 @daily'
placeholder={t('cronPlaceholder')}
value={cron}
onChange={(e) => setCron(e.target.value)}
className='text-xs font-mono'
/>
<p className='text-[10px] text-muted-foreground'>
使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。
{t('cronHint')}
</p>
</div>
<div className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<Label htmlFor='sched-active'>启用状态</Label>
<Label htmlFor='sched-active'>{t('scheduleActive')}</Label>
<p className='text-[10px] text-muted-foreground'>
决定此定时任务是否会定时触发执行。
{t('scheduleActiveDesc')}
</p>
</div>
<Switch
@@ -459,14 +464,14 @@ export function TaskSchedulesManager() {
return (
<div className='flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed'>
<Info className='h-4 w-4 shrink-0' />
<span>所选任务类型不需要运行参数参数。</span>
<span>{t('noParamsRequired')}</span>
</div>
);
}
return (
<div className='space-y-4 pt-2 border-t'>
<Label className='text-xs font-semibold'>
运行参数配置 (Payload)
{t('payloadConfig')}
</Label>
{targetTask.params.map((param) => (
<div
@@ -509,8 +514,8 @@ export function TaskSchedulesManager() {
/>
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? '开启'
: '关闭'}
? t('paramOn')
: t('paramOff')}
</span>
</div>
) : (
@@ -547,7 +552,7 @@ export function TaskSchedulesManager() {
disabled={submitLoading}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
onClick={handleSubmit}
@@ -555,7 +560,7 @@ export function TaskSchedulesManager() {
className='h-8 text-xs'
>
{submitLoading && <Spinner className='size-3 mr-1' />}
{editingSchedule ? '保存修改' : '确认创建'}
{editingSchedule ? t('saveChanges') : t('confirmCreate')}
</Button>
</DialogFooter>
</DialogContent>
@@ -565,10 +570,8 @@ export function TaskSchedulesManager() {
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className='sm:max-w-[400px]'>
<DialogHeader>
<DialogTitle>删除定时任务</DialogTitle>
<DialogDescription>
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
</DialogDescription>
<DialogTitle>{t('deleteScheduleTitle')}</DialogTitle>
<DialogDescription>{t('deleteScheduleDesc')}</DialogDescription>
</DialogHeader>
<DialogFooter className='gap-2 sm:gap-0'>
<Button
@@ -577,7 +580,7 @@ export function TaskSchedulesManager() {
disabled={deleteLoading}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='destructive'
@@ -586,7 +589,7 @@ export function TaskSchedulesManager() {
className='h-8 text-xs'
>
{deleteLoading && <Spinner className='size-3 mr-1' />}
确认删除
{t('confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
+8 -4
View File
@@ -2,6 +2,7 @@
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TaskManager } from './components/task-manager';
import { TaskSchedulesManager } from './components/task-schedules';
@@ -10,6 +11,7 @@ import { Spinner } from '@/components/ui/spinner';
import { Layers } from 'lucide-react';
function TasksPageContent() {
const t = useTranslations('admin.tasks');
const searchParams = useSearchParams();
const router = useRouter();
const activeTab = searchParams.get('tab') || 'tasks';
@@ -22,7 +24,9 @@ function TasksPageContent() {
<div className='flex items-center gap-2'>
<Layers 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>
@@ -36,19 +40,19 @@ function TasksPageContent() {
value='tasks'
className='px-0 pb-2 text-xs font-semibold'
>
任务管理
{t('tabTasks')}
</TabsTrigger>
<TabsTrigger
value='schedules'
className='px-0 pb-2 text-xs font-semibold'
>
定时任务
{t('tabSchedules')}
</TabsTrigger>
<TabsTrigger
value='executions'
className='px-0 pb-2 text-xs font-semibold'
>
任务日志
{t('tabExecutions')}
</TabsTrigger>
</TabsList>
<TabsContent value='tasks' className='space-y-4 outline-none'>
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -31,6 +32,7 @@ export function CreateUserModal({
isOpen: boolean;
onClose: () => void;
}) {
const t = useTranslations('admin.users');
const { createUser } = useAdminUsers();
const [form, setForm] = useState<CreateUserRequest>(emptyForm);
const [saving, setSaving] = useState(false);
@@ -48,21 +50,21 @@ export function CreateUserModal({
const validate = () => {
const newErrors: Record<string, string> = {};
if (!form.username.trim()) {
newErrors.username = '用户名不能为空';
newErrors.username = t('validation.usernameRequired');
} else if (form.username.trim().length < 3) {
newErrors.username = '用户名长度不能少于 3 位';
newErrors.username = t('validation.usernameTooShort');
}
if (!form.email.trim()) {
newErrors.email = '邮箱不能为空';
newErrors.email = t('validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = '邮箱格式不正确';
newErrors.email = t('validation.emailInvalid');
}
if (!form.password) {
newErrors.password = '密码不能为空';
newErrors.password = t('validation.passwordRequired');
} else if (form.password.length < 8) {
newErrors.password = '密码长度不能少于 8 位';
newErrors.password = t('validation.passwordTooShort');
}
setErrors(newErrors);
@@ -93,22 +95,20 @@ export function CreateUserModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>新增用户</DialogTitle>
<DialogDescription>
直接创建一个本地密码登录的新账户。
</DialogDescription>
<DialogTitle>{t('createTitle')}</DialogTitle>
<DialogDescription>{t('createDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className='space-y-4 pt-2'>
<div className='space-y-1.5'>
<Label htmlFor='username'>用户名</Label>
<Label htmlFor='username'>{t('username')}</Label>
<Input
id='username'
value={form.username}
onChange={(e) =>
setForm((prev) => ({ ...prev, username: e.target.value }))
}
placeholder='请输入用户名 (至少 3 位)'
placeholder={t('usernamePlaceholder')}
/>
{errors.username && (
<p className='text-xs text-destructive'>{errors.username}</p>
@@ -116,19 +116,19 @@ export function CreateUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label>
<Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
<Input
id='nickname'
value={form.nickname}
onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value }))
}
placeholder='请输入昵称'
placeholder={t('nicknamePlaceholder')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label>
<Label htmlFor='email'>{t('email')}</Label>
<Input
id='email'
type='email'
@@ -136,7 +136,7 @@ export function CreateUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value }))
}
placeholder='请输入邮箱地址'
placeholder={t('emailPlaceholder')}
/>
{errors.email && (
<p className='text-xs text-destructive'>{errors.email}</p>
@@ -144,7 +144,7 @@ export function CreateUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='password'>密码</Label>
<Label htmlFor='password'>{t('password')}</Label>
<Input
id='password'
type='password'
@@ -152,7 +152,7 @@ export function CreateUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
placeholder='请输入登录密码 (至少 8 位)'
placeholder={t('passwordPlaceholder')}
/>
{errors.password && (
<p className='text-xs text-destructive'>{errors.password}</p>
@@ -161,9 +161,11 @@ export function CreateUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>启用账户</div>
<div className='font-medium text-sm'>
{t('enableAccountLabel')}
</div>
<div className='text-xs text-muted-foreground'>
禁用后此账号将无法登录系统。
{t('enableAccountDesc')}
</div>
</div>
<Switch
@@ -176,9 +178,9 @@ export function CreateUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>管理员权限</div>
<div className='font-medium text-sm'>{t('adminPermission')}</div>
<div className='text-xs text-muted-foreground'>
开启后此账号将拥有后台管理权限。
{t('adminPermissionDesc')}
</div>
</div>
<Switch
@@ -191,10 +193,10 @@ export function CreateUserModal({
<div className='flex justify-end gap-2 pt-2 border-t mt-2'>
<Button variant='outline' type='button' onClick={onClose}>
取消
{t('cancel')}
</Button>
<Button type='submit' disabled={saving} variant='secondary'>
{saving ? '创建中...' : '创建'}
{saving ? t('creating') : t('create')}
</Button>
</div>
</form>
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -43,6 +44,7 @@ export function EditUserModal({
onClose: () => void;
user: AdminUser | null;
}) {
const t = useTranslations('admin.users');
const { updateUser, deleteUser, getUserDetail } = useAdminUsers();
const { user: currentUser } = useAuth();
@@ -96,13 +98,13 @@ export function EditUserModal({
const newErrors: Record<string, string> = {};
if (!form.email.trim()) {
newErrors.email = '邮箱不能为空';
newErrors.email = t('validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = '邮箱格式不正确';
newErrors.email = t('validation.emailInvalid');
}
if (form.password && form.password.length < 8) {
newErrors.password = '密码长度不能少于 8 位';
newErrors.password = t('validation.passwordTooShort');
}
setErrors(newErrors);
@@ -150,10 +152,8 @@ export function EditUserModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>编辑用户</DialogTitle>
<DialogDescription>
修改用户档案、授予/撤销管理权限或重置其密码。
</DialogDescription>
<DialogTitle>{t('editTitle')}</DialogTitle>
<DialogDescription>{t('editDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className='space-y-4 pt-2'>
@@ -162,7 +162,7 @@ export function EditUserModal({
htmlFor='username'
className='text-xs text-muted-foreground'
>
用户名 (不可修改)
{t('usernameImmutable')}
</Label>
<Input
id='username'
@@ -173,19 +173,19 @@ export function EditUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label>
<Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
<Input
id='nickname'
value={form.nickname}
onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value }))
}
placeholder='请输入昵称'
placeholder={t('nicknamePlaceholder')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label>
<Label htmlFor='email'>{t('email')}</Label>
<Input
id='email'
type='email'
@@ -193,7 +193,7 @@ export function EditUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value }))
}
placeholder='请输入邮箱地址'
placeholder={t('emailPlaceholder')}
/>
{errors.email && (
<p className='text-xs text-destructive'>{errors.email}</p>
@@ -201,7 +201,7 @@ export function EditUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='password'>重置密码 (选填)</Label>
<Label htmlFor='password'>{t('resetPasswordOptional')}</Label>
<Input
id='password'
type='password'
@@ -209,7 +209,7 @@ export function EditUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
placeholder='留空表示保持当前密码,输入则重置 (至少 8 位)'
placeholder={t('resetPasswordPlaceholder')}
/>
{errors.password && (
<p className='text-xs text-destructive'>{errors.password}</p>
@@ -218,11 +218,13 @@ export function EditUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>管理员权限</div>
<div className='font-medium text-sm'>
{t('adminPermission')}
</div>
<div className='text-xs text-muted-foreground'>
{isSelf
? '不能撤销当前登录用户的管理员权限。'
: '开启后此账号将拥有后台管理权限。'}
? t('cannotRevokeSelfAdmin')
: t('adminPermissionDesc')}
</div>
</div>
<Switch
@@ -255,7 +257,7 @@ export function EditUserModal({
onClick={() => setShowDeleteConfirm(true)}
disabled={saving}
>
删除用户
{t('deleteUser')}
</Button>
)}
</div>
@@ -266,10 +268,10 @@ export function EditUserModal({
onClick={onClose}
disabled={saving}
>
取消
{t('cancel')}
</Button>
<Button type='submit' disabled={saving} variant='secondary'>
{saving ? '保存中...' : '保存'}
{saving ? t('saving') : t('save')}
</Button>
</div>
</div>
@@ -280,14 +282,15 @@ export function EditUserModal({
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {user.nickname || user.username}{' '}
吗?该操作会移除用户账号,删除后无法撤销。
{t('deleteConfirmDesc', { name: user.nickname || user.username })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={saving}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
@@ -296,7 +299,7 @@ export function EditUserModal({
disabled={saving}
>
{saving && <Loader2 className='size-3 animate-spin mr-1' />}
确认删除
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import {
Globe,
Loader2,
@@ -33,13 +34,14 @@ export function UserDetailSheet({
detailLoading,
onStatusToggle,
}: UserDetailSheetProps) {
const t = useTranslations('admin.users');
const displayValue = (value?: string) =>
value && value.trim() ? value : '-';
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-[400px] w-full p-0 flex flex-col gap-0'>
<SheetTitle className='px-5 py-3'>用户档案</SheetTitle>
<SheetTitle className='px-5 py-3'>{t('userProfile')}</SheetTitle>
{selectedUser && (
<>
@@ -79,14 +81,14 @@ export function UserDetailSheet({
{detailLoading && (
<div className='flex items-center gap-1 text-[10px] text-muted-foreground'>
<Loader2 className='size-3 animate-spin' />
正在刷新详情
{t('refreshingDetail')}
</div>
)}
<div className='gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50'>
<div className='flex flex-col gap-0.5'>
<span className='text-[9px] uppercase tracking-widest text-muted-foreground font-medium'>
注册时间
{t('registeredAt')}
</span>
<span className='font-mono text-xs font-semibold'>
{
@@ -103,13 +105,13 @@ export function UserDetailSheet({
<div className='p-6 space-y-6'>
<div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
个人资料
{t('personalInfo')}
</h4>
<div className='rounded-lg border divide-y bg-background/50'>
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Mail className='size-3' />
邮箱
{t('email')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.email)}
@@ -118,7 +120,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Smartphone className='size-3' />
手机
{t('phone')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.phone)}
@@ -129,7 +131,7 @@ export function UserDetailSheet({
<span className='size-3 flex items-center justify-center font-bold text-[9px]'>
⚧
</span>
性别
{t('gender')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.gender)}
@@ -138,7 +140,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<MapPin className='size-3' />
所在地
{t('location')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.location)}
@@ -147,7 +149,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Globe className='size-3' />
网站
{t('website')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.website)}
@@ -155,7 +157,7 @@ export function UserDetailSheet({
</div>
<div className='flex flex-col gap-2 p-3.5 text-sm'>
<span className='text-[10px] text-muted-foreground'>
简介
{t('bio')}
</span>
<span className='break-words text-[10px] leading-5'>
{displayValue(selectedUser.bio)}
@@ -166,40 +168,44 @@ export function UserDetailSheet({
<div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
系统记录
{t('systemRecords')}
</h4>
<div className='rounded-lg border divide-y bg-background/50'>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>账户状态</span>
<span className='text-[10px]'>
{t('accountStatus')}
</span>
<Badge
variant={
selectedUser.is_active ? 'secondary' : 'outline'
}
className='text-[10px]'
>
{selectedUser.is_active ? '正常' : '禁用'}
{selectedUser.is_active
? t('statusActive')
: t('statusDisabled')}
</Badge>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>管理员</span>
<span className='text-[10px]'>{t('isAdmin')}</span>
<span className='font-mono text-[10px]'>
{selectedUser.is_admin ? '是' : '否'}
{selectedUser.is_admin ? t('yes') : t('no')}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>最后登录</span>
<span className='text-[10px]'>{t('lastLogin')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.last_login_at)}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>注册时间</span>
<span className='text-[10px]'>{t('registeredAt')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.created_at)}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>最后更新</span>
<span className='text-[10px]'>{t('lastUpdate')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.updated_at)}
</span>
@@ -219,12 +225,12 @@ export function UserDetailSheet({
{selectedUser.is_active ? (
<>
<ShieldCheck className='size-3 mr-1' />
封禁账户
{t('banAccount')}
</>
) : (
<>
<UserCheck className='size-3 mr-1' />
解除封禁
{t('unbanAccount')}
</>
)}
</Button>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import {
ChevronDown,
ChevronLeft,
@@ -24,6 +25,7 @@ import { useAdminUsers } from '@/contexts/admin-users-context';
import { cn } from '@/lib/utils';
export function UserFilterBar() {
const t = useTranslations('admin.users');
const {
total,
loading,
@@ -61,7 +63,7 @@ export function UserFilterBar() {
)}
>
<Search className='size-3 mr-1' />
搜索
{t('search')}
{hasSearchFilter && (
<>
<Separator orientation='vertical' className='mx-1' />
@@ -79,19 +81,19 @@ export function UserFilterBar() {
<div className='space-y-2.5'>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入用户 ID...'
placeholder={t('searchUserIdPlaceholder')}
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入 username...'
placeholder={t('searchUsernamePlaceholder')}
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入邮箱...'
placeholder={t('searchEmailPlaceholder')}
value={searchEmail}
onChange={(e) => setSearchEmail(e.target.value)}
/>
@@ -106,7 +108,7 @@ export function UserFilterBar() {
setSearchEmail('');
}}
>
清除
{t('clear')}
</Button>
)}
</div>
@@ -124,7 +126,7 @@ export function UserFilterBar() {
)}
>
<Filter className='size-3' />
状态
{t('status')}
{statusFilter !== 'all' && (
<>
<Separator orientation='vertical' className='mx-1' />
@@ -153,7 +155,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>全部状态</span>
<span className='text-xs'>{t('statusAll')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
@@ -169,7 +171,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>正常</span>
<span className='text-xs'>{t('statusActive')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
@@ -185,7 +187,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>禁用</span>
<span className='text-xs'>{t('statusDisabled')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -205,7 +207,7 @@ export function UserFilterBar() {
className='h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground'
>
<X className='size-3' />
清空筛选
{t('clearFilters')}
</Button>
</>
)}
@@ -215,7 +217,7 @@ export function UserFilterBar() {
<div className='flex items-center gap-1.5 self-end lg:self-auto'>
<span className='text-[10px] text-muted-foreground whitespace-nowrap'>
{total} 条记录
{t('totalRecords', { count: total })}
</span>
<div className='flex items-center border border-dashed rounded-md shadow-none'>
<Button
@@ -249,7 +251,7 @@ export function UserFilterBar() {
className='h-6 border-dashed text-[10px] px-2 font-mono shadow-none'
disabled={loading}
>
{pageSize}条/页
{t('perPage', { count: pageSize })}
<ChevronDown className='size-3 opacity-50' />
</Button>
</DropdownMenuTrigger>
@@ -263,7 +265,7 @@ export function UserFilterBar() {
pageSize === size && 'bg-accent',
)}
>
{size}条/页
{t('perPage', { count: size })}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -275,7 +277,7 @@ export function UserFilterBar() {
className='h-6 w-6 border-dashed shadow-none'
onClick={() => fetchUsers(true)}
disabled={loading}
title='刷新数据'
title={t('refreshData')}
>
<Loader2 className={cn('size-3', loading && 'animate-spin')} />
</Button>
+19 -15
View File
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
@@ -33,6 +34,7 @@ import { UserDetailSheet } from './components/user-detail-sheet';
import { EditUserModal } from './components/edit-user-modal';
export default function UsersPage() {
const t = useTranslations('admin.users');
const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } =
useAdminUsers();
@@ -78,7 +80,9 @@ export default function UsersPage() {
<div className='flex items-center gap-2'>
<UserRound 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>
<Button
@@ -88,7 +92,7 @@ export default function UsersPage() {
onClick={() => setCreateModalOpen(true)}
>
<Plus className='size-3.5 mr-1' />
新增用户
{t('createUser')}
</Button>
</div>
@@ -104,9 +108,9 @@ export default function UsersPage() {
/>
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description='加载用户列表中...' />
<LoadingStateWithBorder icon={Layers} description={t('loadingUsers')} />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description='暂无用户数据' />
<EmptyStateWithBorder icon={UserX} description={t('noUsers')} />
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<TooltipProvider delayDuration={0}>
@@ -117,22 +121,22 @@ export default function UsersPage() {
ID
</TableHead>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
用户
{t('colUser')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[160px] py-2 h-8 pl-4'>
邮箱
{t('colEmail')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次登陆
{t('colLastLogin')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
注册时间
{t('colRegisteredAt')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次更新
{t('colLastUpdate')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -211,10 +215,10 @@ export default function UsersPage() {
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
{user.is_admin
? '管理员账户'
? t('adminAccount')
: user.is_active
? '禁用账户'
: '启用账户'}
? t('disableAccount')
: t('enableAccount')}
</TooltipContent>
</Tooltip>
@@ -230,7 +234,7 @@ export default function UsersPage() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看详情
{t('viewDetail')}
</TooltipContent>
</Tooltip>
@@ -246,7 +250,7 @@ export default function UsersPage() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
编辑用户
{t('editUser')}
</TooltipContent>
</Tooltip>
</div>
+3 -1
View File
@@ -6,8 +6,10 @@ import { motion } from 'motion/react';
import { RequireAuth } from '@/components/auth/require-auth';
import { UserFileManager } from '@/components/common/user/file-manager';
import { FolderOpen } from 'lucide-react';
import { useTranslations } from 'next-intl';
export default function UserFilesPage() {
const t = useTranslations('files');
return (
<RequireAuth>
<motion.div
@@ -22,7 +24,7 @@ export default function UserFilesPage() {
<FolderOpen className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
我的文件
{t('myFiles')}
</h1>
</div>
</div>
+5 -5
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { motion } from 'motion/react';
import { ArrowLeft, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'next-intl';
/**
* 404 Not Found Page
@@ -12,6 +13,7 @@ import { Button } from '@/components/ui/button';
*/
export default function NotFound() {
const router = useRouter();
const t = useTranslations('notFound');
return (
<div className='relative min-h-screen w-full flex flex-col items-center justify-center bg-background overflow-hidden selection:bg-primary/20'>
@@ -43,9 +45,7 @@ export default function NotFound() {
className='space-y-8'
>
<p className='text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed'>
抱歉,您访问的页面似已迷失在数字星云中。
<br />
请检查路径或返回安全地带。
{t('description')}
</p>
<div className='flex justify-center gap-4'>
@@ -56,7 +56,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300'
>
<ArrowLeft className='size-3 opacity-70' />
上一页
{t('previousPage')}
</Button>
<Link href='/'>
@@ -66,7 +66,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300'
>
<Home className='size-3' />
首页
{t('home')}
</Button>
</Link>
</div>