mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
fix(frontend): keep browser API on same-origin rewrites in dev
Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local next dev so axios hits /api/* and Next rewrites proxy to the backend.
This commit is contained in:
@@ -1,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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user