fix(frontend): keep browser API on same-origin rewrites in dev

Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local
next dev so axios hits /api/* and Next rewrites proxy to the backend.
This commit is contained in:
ryan
2026-07-24 16:37:24 +08:00
parent a4588b05e0
commit 9f15f42b47
75 changed files with 5971 additions and 2450 deletions
+7 -2
View File
@@ -1,11 +1,16 @@
# 前端 URL
WAVELET_FRONTEND_URL=http://localhost:3000
# 后端 URL
# 后端 URL(服务端与 next.config rewrites 使用,不会暴露到浏览器)
WAVELET_BACKEND_URL=http://localhost:8000
# 浏览器侧 API base URL
# - 本地 `pnpm dev`:务必留空或不设置,请求走同源 `/api/*`,由 Next rewrites 反代到 WAVELET_BACKEND_URL
# - 静态导出 / 浏览器需直连后端(如部分 WebSocket 场景)时再设置,例如:
# NEXT_PUBLIC_WAVELET_BACKEND_URL=http://localhost:8000
# Session Cookie
WAVELET_SESSION_COOKIE_NAME=wavelet_session_id
# 环境
NODE_ENV=production
NODE_ENV=development
+11 -7
View File
@@ -1,17 +1,21 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api';
import {
getApiSections,
DOCS_LAST_UPDATED,
} from '@/components/common/docs/api';
export default async function ApiDocPage() {
const t = await getTranslations('docs');
export default function ApiDocPage() {
return (
<LegalPageLayout
title='API 接口文档'
title={t('api.title')}
lastUpdated={DOCS_LAST_UPDATED}
sections={apiSections}
sections={getApiSections(t)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
Wavelet 提供简单、强大的 API
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
接口,您可以轻松地与后端服务进行集成与二次开发。
{t('api.description')}
</p>
}
/>
+8 -6
View File
@@ -1,17 +1,19 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { howToUseSections } from '@/components/common/docs/how-to-use';
import { getHowToUseSections } from '@/components/common/docs/how-to-use';
import { DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function HowToUsePage() {
export default async function HowToUsePage() {
const t = await getTranslations('docs');
return (
<LegalPageLayout
title='平台使用指南'
title={t('howToUse.title')}
lastUpdated={DOCS_LAST_UPDATED}
sections={howToUseSections}
sections={getHowToUseSections(t)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
欢迎使用 Wavelet
脚手架系统。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
{t('howToUse.description')}
</p>
}
/>
@@ -1,18 +1,21 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { privacySections } from '@/components/common/docs/privacy';
import { getPrivacySections } from '@/components/common/docs/privacy';
import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms';
export default function PrivacyPolicyPage() {
export default async function PrivacyPolicyPage() {
const t = await getTranslations('docs');
return (
<LegalPageLayout
title='隐私政策 (Privacy)'
title={t('privacy.title')}
lastUpdated={TERMS_LAST_UPDATED}
sections={privacySections}
sections={getPrivacySections(t)}
description={
<span>
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
{t('privacy.description')}
<br className='hidden md:block' />
以及您享有的相关权利。
{t('privacy.descriptionRights')}
</span>
}
/>
@@ -1,16 +1,19 @@
import { getTranslations } from 'next-intl/server';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
TERMS_LAST_UPDATED,
termsSections,
getTermsSections,
} from '@/components/common/docs/terms';
export default function TermsOfServicePage() {
export default async function TermsOfServicePage() {
const t = await getTranslations('docs');
return (
<LegalPageLayout
title='服务协议 (Terms)'
title={t('terms.title')}
lastUpdated={TERMS_LAST_UPDATED}
sections={termsSections}
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
sections={getTermsSections(t)}
description={<span>{t('terms.description')}</span>}
/>
);
}
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -52,6 +53,7 @@ interface CacheManagerProps {
}
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const t = useTranslations('admin.database.cache');
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
const [loadingCache, setLoadingCache] = useState<boolean>(true);
const [savingConfig, setSavingConfig] = useState<boolean>(false);
@@ -64,22 +66,25 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const [lruEnabled, setLruEnabled] = useState<boolean>(true);
// 获取磁盘缓存状态
const fetchCacheStatus = useCallback(async (isSilent = false) => {
if (!isSilent) setLoadingCache(true);
try {
const data = await services.adminCache.getCacheStatus();
setCacheStatus(data);
setMaxSizeMB(data.max_size_mb.toString());
setTtlMinutes(data.ttl_minutes.toString());
setLruEnabled(data.lru_enabled);
} catch (err) {
toast.error('获取磁盘缓存状态失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingCache(false);
}
}, []);
const fetchCacheStatus = useCallback(
async (isSilent = false) => {
if (!isSilent) setLoadingCache(true);
try {
const data = await services.adminCache.getCacheStatus();
setCacheStatus(data);
setMaxSizeMB(data.max_size_mb.toString());
setTtlMinutes(data.ttl_minutes.toString());
setLruEnabled(data.lru_enabled);
} catch (err) {
toast.error(t('fetchCacheStatusFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingCache(false);
}
},
[t],
);
// 保存配置
const handleSaveConfig = async (e: React.FormEvent) => {
@@ -87,14 +92,14 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const size = parseInt(maxSizeMB, 10);
const ttl = parseInt(ttlMinutes, 10);
if (isNaN(size) || size < 1) {
toast.error('保存失败', {
description: '最大容量限制必须是大于等于 1 的整数',
toast.error(t('saveFailed'), {
description: t('maxSizeMustBePositive'),
});
return;
}
if (isNaN(ttl) || ttl < 0) {
toast.error('保存失败', {
description: '默认过期时间必须是大于等于 0 的整数',
toast.error(t('saveFailed'), {
description: t('ttlMustBeNonNegative'),
});
return;
}
@@ -105,10 +110,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
ttl_minutes: ttl,
lru_enabled: lruEnabled,
});
toast.success('保存成功', { description: '磁盘缓存策略已热更新' });
toast.success(t('saveSuccess'), {
description: t('cachePolicyHotUpdated'),
});
await fetchCacheStatus(true);
} catch (err) {
toast.error('保存配置失败', {
toast.error(t('saveConfigFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
@@ -121,11 +128,11 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
setClearingCache(true);
try {
await services.adminCache.clearCache();
toast.success('清空成功', { description: '缓存数据已全部清除' });
toast.success(t('clearSuccess'), { description: t('cacheDataCleared') });
setShowClearConfirm(false);
await fetchCacheStatus(true);
} catch (err) {
toast.error('清空缓存失败', {
toast.error(t('clearCacheFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
@@ -149,10 +156,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<div className='space-y-0.5'>
<div className='flex items-center gap-2'>
<HardDrive className='size-4 text-primary animate-pulse' />
<CardTitle className='text-sm font-semibold'>缓存管理</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('cacheManagement')}
</CardTitle>
</div>
<CardDescription className='text-[11px]'>
管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略
{t('cacheManagementDesc')}
</CardDescription>
</div>
@@ -166,7 +175,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<RefreshCw
className={`size-3 ${loadingCache ? 'animate-spin' : ''}`}
/>
刷新状态
{t('refreshStatus')}
</Button>
</CardHeader>
<CardContent className='pt-4'>
@@ -181,13 +190,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* 左边:状态区 (2/5 cols) */}
<div className='lg:col-span-2 space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
运行状态
{t('runtimeStatus')}
</h4>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
{/* 已占空间 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1'>
已用空间
{t('usedSpace')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{formatBytes(cacheStatus.total_size)}
@@ -196,12 +205,12 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* Key数量 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1'>
缓存键数量
{t('cacheKeysCount')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{formatNumber(cacheStatus.keys_count)}{' '}
<span className='text-xs text-muted-foreground font-normal'>
个文件
{t('files')}
</span>
</p>
</div>
@@ -210,7 +219,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{/* 存储路径 */}
<div className='p-4 rounded-xl border border-border/40 bg-background/30 backdrop-blur-xs hover:border-primary/20 transition-all duration-300'>
<p className='text-[10px] text-muted-foreground font-medium mb-1.5'>
缓存基准目录
{t('cacheBaseDirectory')}
</p>
<code
className='text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate'
@@ -225,20 +234,20 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
className='h-8 text-xs font-medium w-full'
onClick={() => setShowClearConfirm(true)}
>
立即清空缓存
{t('clearCacheNow')}
</Button>
</div>
{/* 右边:配置区 (3/5 cols) */}
<div className='lg:col-span-3 border-t lg:border-t-0 lg:border-l border-border/40 pt-6 lg:pt-0 lg:pl-6 space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
策略配置
{t('policyConfig')}
</h4>
<form onSubmit={handleSaveConfig} className='space-y-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label htmlFor='maxSizeMB' className='text-xs font-medium'>
最大容量限制 (MB)
{t('maxSizeLimit')}
</Label>
<Input
id='maxSizeMB'
@@ -251,13 +260,13 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
required
/>
<p className='text-[9px] text-muted-foreground'>
当总大小超出该值时,自动触发淘汰算法
{t('maxSizeHint')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='ttlMinutes' className='text-xs font-medium'>
生存时间限制 (分钟)
{t('ttlLimit')}
</Label>
<Input
id='ttlMinutes'
@@ -270,7 +279,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
required
/>
<p className='text-[9px] text-muted-foreground'>
缓存项的最长存活时间,超期后将失效被清理
{t('ttlHint')}
</p>
</div>
</div>
@@ -281,10 +290,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
htmlFor='lruEnabled'
className='text-xs font-semibold block cursor-pointer'
>
启用 LRU 淘汰机制
{t('enableLru')}
</Label>
<span className='text-[10px] text-muted-foreground block'>
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
{t('enableLruDesc')}
</span>
</div>
<Switch
@@ -300,7 +309,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
disabled={savingConfig}
className='h-8 text-xs px-4'
>
{savingConfig ? '正在保存...' : '保存设置'}
{savingConfig ? t('saving') : t('saveSettings')}
</Button>
</div>
</form>
@@ -309,7 +318,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
) : (
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
<HardDrive className='size-8 opacity-45 mb-2' />
<span className='text-xs'>未加载到缓存状态信息</span>
<span className='text-xs'>{t('noCacheStatus')}</span>
</div>
)}
</CardContent>
@@ -320,11 +329,10 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
<DialogHeader>
<DialogTitle className='text-sm font-semibold flex items-center gap-2 text-destructive'>
<Trash2 className='size-4' />
确认清空所有缓存?
{t('confirmClearAllCache')}
</DialogTitle>
<DialogDescription className='text-xs text-muted-foreground pt-1'>
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为
0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
{t('confirmClearDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter className='flex gap-2 justify-end mt-4'>
@@ -335,7 +343,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
onClick={() => setShowClearConfirm(false)}
disabled={clearingCache}
>
取消
{t('cancel')}
</Button>
<Button
variant='destructive'
@@ -344,7 +352,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
onClick={handleClearCache}
disabled={clearingCache}
>
{clearingCache ? '清理中...' : '确认清空'}
{clearingCache ? t('clearing') : t('confirmClear')}
</Button>
</DialogFooter>
</DialogContent>
@@ -7,6 +7,7 @@ import CodeMirror from '@uiw/react-codemirror';
import { sql } from '@codemirror/lang-sql';
import { toast } from 'sonner';
import { ArrowLeft, Play, RefreshCw, Terminal, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
@@ -49,6 +50,7 @@ interface SQLConsoleProps {
}
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
const t = useTranslations('admin.database.sql');
// SQL 控制台状态
const [sqlQuery, setSqlQuery] = useState<string>('');
const [executingSQL, setExecutingSQL] = useState<boolean>(false);
@@ -97,10 +99,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
try {
const result = await DbManageService.executeSQL(sqlQuery);
setSqlResult(result);
toast.success('SQL 执行成功');
toast.success(t('sqlExecSuccess'));
} catch (err) {
setSqlError(err instanceof Error ? err.message : '未知执行错误');
toast.error('SQL 执行失败');
toast.error(t('sqlExecFailed'));
} finally {
setExecutingSQL(false);
}
@@ -134,7 +136,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<Terminal className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
SQL 查询终端
{t('sqlQueryTerminal')}
</h1>
</div>
</div>
@@ -153,34 +155,34 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
<div className='flex items-center gap-2'>
<Terminal className='size-4 text-primary' />
<span className='text-xs font-semibold'>SQL 编辑器</span>
<span className='text-xs font-semibold'>{t('sqlEditor')}</span>
</div>
<div className='flex items-center gap-3 flex-wrap'>
{/* 快速模板 */}
<div className='flex items-center gap-1.5'>
<span className='text-[11px] text-muted-foreground'>
快速模板:
{t('quickTemplates')}
</span>
<Select onValueChange={handlePresetSQLChange}>
<SelectTrigger className='h-7 w-[180px] text-[11px] bg-background'>
<SelectValue placeholder='选择预设 SQL' />
<SelectValue placeholder={t('selectPresetSQL')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='SELECT * FROM users LIMIT 10;'>
查询用户 (SELECT users)
{t('queryUsers')}
</SelectItem>
<SelectItem value='SELECT * FROM uploads LIMIT 10;'>
查询文件 (SELECT uploads)
{t('queryFiles')}
</SelectItem>
<SelectItem value='SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;'>
查询任务流水 (SELECT task_executions)
{t('queryTaskExecutions')}
</SelectItem>
<SelectItem value='SELECT sqlite_version();'>
SQLite 版本 (SQLite only)
{t('sqliteVersion')}
</SelectItem>
<SelectItem value='SELECT version();'>
PostgreSQL 版本 (Postgres only)
{t('postgresVersion')}
</SelectItem>
</SelectContent>
</Select>
@@ -204,7 +206,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
disabled={executingSQL || !sqlQuery.trim()}
>
<Play className='size-3' />
{executingSQL ? '运行中...' : '运行'}
{executingSQL ? t('running') : t('run')}
</Button>
</div>
</div>
@@ -245,10 +247,10 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex-1 min-h-[100px] flex flex-col overflow-hidden bg-muted/10'>
{/* 结果栏工具提示 */}
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono'>
<span className='font-semibold'>执行输出</span>
<span className='font-semibold'>{t('executionOutput')}</span>
{sqlResult && (
<span>
类型: {sqlResult.type.toUpperCase()} | 耗时:{' '}
{t('type')}: {sqlResult.type.toUpperCase()} | {t('duration')}:{' '}
{sqlResult.execution_time_ms} ms
</span>
)}
@@ -260,7 +262,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
<div className='flex flex-col items-center justify-center h-full py-10 space-y-2'>
<RefreshCw className='size-5 text-primary animate-spin' />
<span className='text-xs text-muted-foreground'>
正在在数据库执行查询,请稍候...
{t('executingQuery')}
</span>
</div>
)}
@@ -319,7 +321,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
colSpan={sqlResult.columns.length}
className='text-center py-10 text-muted-foreground'
>
查询结果为空
{t('queryResultEmpty')}
</TableCell>
</TableRow>
)}
@@ -331,11 +333,13 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
{sqlResult.type === 'exec' && (
<div className='bg-primary/5 border border-primary/10 text-primary-foreground font-mono text-xs p-6 rounded-lg text-center my-auto'>
<p className='text-foreground text-sm font-semibold'>
SQL 执行成功
{t('sqlExecSuccessTitle')}
</p>
<p className='text-muted-foreground mt-2'>
受影响行数: {sqlResult.affected_rows} 行,耗时{' '}
{sqlResult.execution_time_ms} 毫秒。
{t('affectedRows', {
rows: sqlResult.affected_rows,
ms: sqlResult.execution_time_ms,
})}
</p>
</div>
)}
@@ -345,9 +349,7 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
{!executingSQL && !sqlResult && !sqlError && (
<div className='flex flex-col items-center justify-center h-full py-10 text-muted-foreground opacity-60'>
<Terminal className='size-8 mb-2' />
<span className='text-xs'>
编辑器就绪,请在上方编写 SQL 并运行查看结果
</span>
<span className='text-xs'>{t('editorReady')}</span>
</div>
)}
</div>
@@ -4,6 +4,7 @@ import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -67,6 +68,7 @@ export function TableBrowser({
loadingTables,
refreshTrigger,
}: TableBrowserProps) {
const t = useTranslations('admin.database.tableBrowser');
const [selectedTable, setSelectedTable] = useState<string>('');
const [tableData, setTableData] = useState<TableDataResponse | null>(null);
const [page, setPage] = useState<number>(1);
@@ -125,9 +127,11 @@ export function TableBrowser({
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
<CardHeader className='pb-3 border-b border-dashed flex flex-col md:flex-row md:items-center md:justify-between gap-4'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>数据表浏览器</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('tableBrowser')}
</CardTitle>
<CardDescription className='text-[11px]'>
浏览数据库中的物理数据表详情及内容
{t('tableBrowserDesc')}
</CardDescription>
</div>
@@ -137,7 +141,7 @@ export function TableBrowser({
) : (
<Select value={selectedTable} onValueChange={handleTableChange}>
<SelectTrigger className='h-8 w-[200px] text-xs bg-background border-border/40'>
<SelectValue placeholder='选择数据表' />
<SelectValue placeholder={t('selectTable')} />
</SelectTrigger>
<SelectContent className='max-h-[300px]'>
{tables.map((t) => (
@@ -202,7 +206,7 @@ export function TableBrowser({
colSpan={tableData.columns.length}
className='text-center py-10 text-muted-foreground'
>
表中无记录数据
{t('noRecordsInTable')}
</TableCell>
</TableRow>
)}
@@ -213,11 +217,11 @@ export function TableBrowser({
{/* 分页控制 */}
<div className='flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed'>
<div>
总行数:{' '}
{t('totalRows')}:{' '}
<span className='font-mono text-foreground font-semibold'>
{formatNumber(tableData.total)}
</span>{' '}
条记录
{t('records')}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -227,11 +231,13 @@ export function TableBrowser({
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1 || loadingData}
>
上一页
{t('prevPage')}
</Button>
<span className='text-xs px-2 font-mono'>
第 {page} /{' '}
{Math.max(1, Math.ceil(tableData.total / pageSize))} 页
{t('pageIndicator', {
current: page,
total: Math.max(1, Math.ceil(tableData.total / pageSize)),
})}
</span>
<Button
variant='outline'
@@ -242,7 +248,7 @@ export function TableBrowser({
page >= Math.ceil(tableData.total / pageSize) || loadingData
}
>
下一页
{t('nextPage')}
</Button>
</div>
</div>
@@ -250,7 +256,7 @@ export function TableBrowser({
) : (
<div className='flex flex-col items-center justify-center py-10 text-muted-foreground'>
<Layers className='size-8 opacity-45 mb-2' />
<span className='text-xs'>未选择数据表或表结构无法加载</span>
<span className='text-xs'>{t('noTableSelected')}</span>
</div>
)}
</CardContent>
@@ -16,6 +16,7 @@ import {
Server,
Terminal,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import {
Card,
@@ -57,6 +58,7 @@ const formatNumber = (num: number | string) => {
};
export function DatabasePageClient() {
const t = useTranslations('admin.database');
// 核心状态
const [overview, setOverview] = useState<DBOverview | null>(null);
const [tables, setTables] = useState<string[]>([]);
@@ -69,19 +71,22 @@ export function DatabasePageClient() {
const [refreshTrigger, setRefreshTrigger] = useState<number>(0);
// 1. 获取运行概览
const fetchOverview = useCallback(async (isSilent = false) => {
if (!isSilent) setLoadingOverview(true);
try {
const data = await services.dbManage.getOverview();
setOverview(data);
} catch (err) {
toast.error('获取数据库概览失败', {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingOverview(false);
}
}, []);
const fetchOverview = useCallback(
async (isSilent = false) => {
if (!isSilent) setLoadingOverview(true);
try {
const data = await services.dbManage.getOverview();
setOverview(data);
} catch (err) {
toast.error(t('fetchOverviewFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingOverview(false);
}
},
[t],
);
// 2. 获取表列表
const fetchTables = useCallback(async () => {
@@ -90,13 +95,13 @@ export function DatabasePageClient() {
const data = await services.dbManage.listTables();
setTables(data);
} catch (err) {
toast.error('获取数据库数据表列表失败', {
toast.error(t('fetchTablesFailed'), {
description: err instanceof Error ? err.message : '未知错误',
});
} finally {
setLoadingTables(false);
}
}, []);
}, [t]);
// 3. 协调刷新
const handleRefreshAll = () => {
@@ -118,11 +123,11 @@ export function DatabasePageClient() {
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success('数据库导出成功', {
description: `已下载归档文件: ${filename}`,
toast.success(t('exportSuccess'), {
description: t('exportSuccessDesc', { filename }),
});
} catch (err) {
toast.error('数据库导出失败', {
toast.error(t('exportFailed'), {
description: err instanceof Error ? err.message : '导出异常',
});
} finally {
@@ -170,7 +175,9 @@ export function DatabasePageClient() {
<div className='flex items-center gap-2'>
<Database className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>数据管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
<Button
@@ -183,7 +190,7 @@ export function DatabasePageClient() {
<RefreshCw
className={`size-3 ${loadingOverview ? 'animate-spin' : ''}`}
/>
刷新数据
{t('refreshData')}
</Button>
</div>
@@ -196,7 +203,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
数据库类型
{t('dbType')}
</CardDescription>
<Server className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -211,7 +218,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300 col-span-1 md:col-span-2 lg:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
版本信息
{t('versionInfo')}
</CardDescription>
<Cpu className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -229,7 +236,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
名称/路径
{t('nameOrPath')}
</CardDescription>
<Database className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -249,7 +256,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
数据库大小
{t('dbSize')}
</CardDescription>
<HardDrive className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -262,7 +269,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
物理数据表
{t('physicalTables')}
</CardDescription>
<Layers className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -277,7 +284,7 @@ export function DatabasePageClient() {
<Card className='shadow-sm border-border/40 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-all duration-300'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardDescription className='text-[10px] font-medium'>
活跃连接数
{t('activeConnections')}
</CardDescription>
<Activity className='size-3.5 text-muted-foreground' />
</CardHeader>
@@ -303,9 +310,11 @@ export function DatabasePageClient() {
{/* 4. 底部功能卡片区 */}
<Card className='border-border/40 bg-card/50 backdrop-blur-sm shadow-sm'>
<CardHeader className='pb-3 border-b border-dashed'>
<CardTitle className='text-sm font-semibold'>功能区</CardTitle>
<CardTitle className='text-sm font-semibold'>
{t('featureArea')}
</CardTitle>
<CardDescription className='text-[11px]'>
数据库导出及自定义高级 SQL 执行终端
{t('featureAreaDesc')}
</CardDescription>
</CardHeader>
<CardContent className='pt-4'>
@@ -315,11 +324,10 @@ export function DatabasePageClient() {
<div className='space-y-1 pr-4'>
<p className='text-xs font-semibold flex items-center gap-1.5'>
<Download className='size-4 text-primary' />
数据库备份导出
{t('dbBackupExport')}
</p>
<p className='text-[10px] text-muted-foreground'>
直接导出并下载物理数据库镜像文件(SQLite 导出 .db,PostgreSQL
导出为打包的 .sql 文本文件)
{t('dbBackupExportDesc')}
</p>
</div>
<Button
@@ -332,7 +340,7 @@ export function DatabasePageClient() {
<RefreshCw
className={`size-3 ${exporting ? 'animate-spin' : ''}`}
/>
{exporting ? '正在准备...' : '开始导出'}
{exporting ? t('preparing') : t('startExport')}
</Button>
</div>
@@ -341,11 +349,10 @@ export function DatabasePageClient() {
<div className='space-y-1 pr-4'>
<p className='text-xs font-semibold flex items-center gap-1.5'>
<Terminal className='size-4 text-primary' />
SQL 查询控台
{t('sqlQueryConsole')}
</p>
<p className='text-[10px] text-muted-foreground'>
打开在线 SQL 控制终端,执行原生的 SQL
进行数据筛选、更新、调试或性能优化
{t('sqlQueryConsoleDesc')}
</p>
</div>
<Button
@@ -356,7 +363,7 @@ export function DatabasePageClient() {
}}
>
<FileText className='size-3.5' />
进入控台
{t('enterConsole')}
</Button>
</div>
</div>
+31 -29
View File
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
import {
@@ -21,6 +22,7 @@ import { TableTab } from './tabs/table-tab';
import { ControlsTab } from './tabs/controls-tab';
export default function HeaderDemoPage() {
const t = useTranslations('admin.demo');
const [activeTab, setActiveTab] = React.useState('dashboard');
return (
@@ -30,7 +32,7 @@ export default function HeaderDemoPage() {
<Code className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
开发规范示例
{t('pageTitle')}
</h1>
</div>
</div>
@@ -40,28 +42,28 @@ export default function HeaderDemoPage() {
<CardHeader className='pb-3'>
<div className='flex items-center gap-2'>
<Code className='size-4 text-primary' />
<CardTitle className='text-base font-semibold'>规范说明</CardTitle>
<CardTitle className='text-base font-semibold'>
{t('specTitle')}
</CardTitle>
</div>
<CardDescription>
此页面展示了后台 Sidebar 关联页面的标准标题栏布局,供开发参考。
</CardDescription>
<CardDescription>{t('specDescription')}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<h3 className='text-xs font-semibold uppercase tracking-wider text-muted-foreground'>
核心规范细节与编写原则:
{t('coreSpecTitle')}
</h3>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
<strong>结构专注</strong>
:页面标题区必须保持绝对干净。请不要在标题下方放置任何描述段落或小字副标题,让页面在视线进入时保持极简与信息纯粹。
<strong>{t('rules.structureFocus.title')}</strong>
{t('rules.structureFocus.description')}
</li>
<li>
<strong>容器与上边距对齐</strong>:使用{' '}
<strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2
</code>{' '}
作为标题行基础容器(不含下边框),外层统一使用{' '}
{t('rules.containerAlignment.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 px-1
</code>{' '}
@@ -69,53 +71,53 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6
</code>
) 进行上边距的视觉对齐,确保切换菜单时顶部高度感一致。
) {t('rules.containerAlignment.description2')}
</li>
<li>
<strong>图标呈现</strong>:直接将 Lucide
图标组件嵌套于标题容器中,应用{' '}
<strong>{t('rules.iconPresentation.title')}</strong>:直接将
Lucide 图标组件嵌套于标题容器中,应用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
size-5 text-primary
</code>{' '}
样式。禁止为图标包裹任何背景色小卡片、圆角或装饰性边框。
{t('rules.iconPresentation.description')}
</li>
<li>
<strong>文本样式标准</strong>:文本使用且仅使用{' '}
<strong>{t('rules.textStyle.title')}</strong>:文本使用且仅使用{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'}
</code>
,禁用字重加粗(如{' '}
{t('rules.textStyle.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-bold
</code>
)或任何渐变色,保证全站字体一致性。
{t('rules.textStyle.description2')}
</li>
<li>
<strong>Tabs 模块化拆分</strong>:凡是带有多个 Tab
页切换的页面,**禁止**将所有 Tab
<strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
Tab 页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx
</code>{' '}
或就近放在{' '}
{t('rules.tabsModular.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/
</code>{' '}
下),主页面文件只负责维护当前 Tab 的激活状态及外层骨架布局。
{t('rules.tabsModular.description2')}
</li>
<li>
<strong>复杂度驱动的区块拆分</strong>:不仅是 Tabs
切换,当一个页面代码行数超过 600
行时,必须将高密度区块拆分为子组件。路由专属、不复用的子组件应放在对应路由的特征目录中(如{' '}
<strong>{t('rules.complexityDriven.title')}</strong>:不仅是
Tabs 切换,当一个页面代码行数超过 600
{t('rules.complexityDriven.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/
</code>
),只有真正跨页面复用的组件才进入全局{' '}
{t('rules.complexityDriven.description2')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
components/common/
</code>
。典型标杆案例参考“数据管理” (
{t('rules.complexityDriven.description3')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
/admin/database
</code>
@@ -145,19 +147,19 @@ export default function HeaderDemoPage() {
value='dashboard'
className='px-0 pb-2 text-xs font-semibold'
>
仪表盘与指标卡片
{t('tabs.dashboard')}
</TabsTrigger>
<TabsTrigger
value='table'
className='px-0 pb-2 text-xs font-semibold'
>
数据表格规范
{t('tabs.table')}
</TabsTrigger>
<TabsTrigger
value='controls'
className='px-0 pb-2 text-xs font-semibold'
>
输入控件与表单
{t('tabs.controls')}
</TabsTrigger>
</TabsList>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
import {
Card,
@@ -25,6 +26,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
export function ControlsTab() {
const t = useTranslations('admin.demo.controls');
const [formData, setFormData] = React.useState({
name: '',
category: 'database',
@@ -38,11 +40,9 @@ export function ControlsTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold'>
表单与输入框交互演示
{t('formDemoTitle')}
</CardTitle>
<CardDescription>
包含按钮变体、下拉选择、多选框及基础输入框的标准样式
</CardDescription>
<CardDescription>{t('formDemoDesc')}</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
{/* 输入框 */}
@@ -51,7 +51,7 @@ export function ControlsTab() {
htmlFor='task-name'
className='text-xs font-medium text-foreground'
>
任务名称
{t('taskName')}
</Label>
<Input
id='task-name'
@@ -59,18 +59,18 @@ export function ControlsTab() {
onChange={(e) =>
setFormData((prev) => ({ ...prev, name: e.target.value }))
}
placeholder='输入要创建的任务描述...'
placeholder={t('taskNamePlaceholder')}
className='h-8 text-xs shadow-none bg-background focus-visible:ring-1'
/>
<p className='text-[10px] text-muted-foreground'>
任务名称将用于后台执行记录的可读性显示
{t('taskNameHint')}
</p>
</div>
{/* 下拉框 */}
<div className='space-y-1.5'>
<Label className='text-xs font-medium text-foreground'>
任务分类
{t('taskCategory')}
</Label>
<Select
value={formData.category}
@@ -82,9 +82,11 @@ export function ControlsTab() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='database'>数据库运维</SelectItem>
<SelectItem value='sync'>数据批量同步</SelectItem>
<SelectItem value='cleanup'>过期磁盘清理</SelectItem>
<SelectItem value='database'>
{t('categoryDatabase')}
</SelectItem>
<SelectItem value='sync'>{t('categorySync')}</SelectItem>
<SelectItem value='cleanup'>{t('categoryCleanup')}</SelectItem>
</SelectContent>
</Select>
</div>
@@ -107,10 +109,10 @@ export function ControlsTab() {
htmlFor='allow-notify'
className='text-xs font-medium text-foreground cursor-pointer select-none leading-none'
>
允许触发异步通知推送
{t('allowNotify')}
</label>
<p className='text-[10px] text-muted-foreground leading-normal'>
勾选后,在当前后台任务顺利执行结束时,会关联派发多渠道通知推送。
{t('allowNotifyDesc')}
</p>
</div>
</div>
@@ -118,32 +120,32 @@ export function ControlsTab() {
{/* 按钮变体 */}
<div className='pt-2'>
<div className='text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2'>
按钮样式对齐:
{t('buttonStyles')}
</div>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' className='h-8 text-xs shadow-none'>
主要操作 (Primary)
{t('primaryButton')}
</Button>
<Button
variant='secondary'
size='sm'
className='h-8 text-xs shadow-none'
>
次要操作 (Secondary)
{t('secondaryButton')}
</Button>
<Button
variant='outline'
size='sm'
className='h-8 border-dashed text-xs shadow-none'
>
辅助线框 (Outline)
{t('outlineButton')}
</Button>
<Button
variant='ghost'
size='sm'
className='h-8 text-xs hover:bg-muted'
>
幽灵按钮 (Ghost)
{t('ghostButton')}
</Button>
</div>
</div>
@@ -155,13 +157,13 @@ export function ControlsTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
表单设计规范
{t('formDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4 text-xs text-muted-foreground'>
<div className='space-y-2 leading-relaxed'>
<p className='font-semibold text-foreground text-[11px]'>
1. 统一控件高度与投影:
{t('spec.unifiedHeight')}
</p>
<p>
输入框 (
@@ -188,7 +190,7 @@ export function ControlsTab() {
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
2. Checkbox 与文本的对齐:
{t('spec.checkboxAlignment')}
</p>
<p>
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
@@ -203,7 +205,7 @@ export function ControlsTab() {
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
3. 按钮使用策略:
{t('spec.buttonStrategy')}
</p>
<p>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
@@ -4,11 +4,13 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Cpu, Database, HardDrive } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
export function DashboardTab() {
const t = useTranslations('admin.demo.dashboard');
return (
<div className='space-y-6'>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
@@ -16,7 +18,7 @@ export function DashboardTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
容器 CPU 使用率
{t('cpuUsage')}
</span>
<Cpu className='size-4 text-primary' />
</CardHeader>
@@ -25,7 +27,7 @@ export function DashboardTab() {
<Progress value={42.8} className='h-1.5' />
<p className='text-[10px] text-muted-foreground flex items-center gap-1'>
<span className='size-1.5 rounded-full bg-emerald-500 inline-block animate-pulse' />
容器运行正常,负载平稳
{t('cpuStatus')}
</p>
</CardContent>
</Card>
@@ -34,7 +36,7 @@ export function DashboardTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
存储已用容量
{t('storageUsage')}
</span>
<HardDrive className='size-4 text-primary' />
</CardHeader>
@@ -45,7 +47,7 @@ export function DashboardTab() {
</div>
<Progress value={72.4} className='h-1.5' />
<p className='text-[10px] text-muted-foreground'>
数据已同步远端 S3 存储桶,支持自动过期
{t('storageStatus')}
</p>
</CardContent>
</Card>
@@ -54,7 +56,7 @@ export function DashboardTab() {
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between pb-2'>
<span className='text-xs font-medium text-muted-foreground'>
物理数据库
{t('database')}
</span>
<Database className='size-4 text-primary' />
</CardHeader>
@@ -67,17 +69,15 @@ export function DashboardTab() {
</div>
<div className='flex items-center gap-3 text-xs text-muted-foreground'>
<div>
活跃连接:{' '}
{t('activeConnections')}{' '}
<span className='font-semibold text-foreground'>12</span>
</div>
<div>
总表数量:{' '}
{t('totalTables')}{' '}
<span className='font-semibold text-foreground'>34</span>
</div>
</div>
<p className='text-[10px] text-muted-foreground'>
链接池自动维护中,最大连接限制为 100
</p>
<p className='text-[10px] text-muted-foreground'>{t('dbStatus')}</p>
</CardContent>
</Card>
</div>
@@ -87,45 +87,45 @@ export function DashboardTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
指标卡片 & 仪表盘设计规范
{t('dashboardDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
指标卡片统一采用{' '}
{t('spec.dashedBorder')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none
</code>{' '}
作为边框和投影。
{t('spec.dashedBorder2')}
</li>
<li>
头部使用{' '}
{t('spec.headerLayout')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex flex-row items-center justify-between pb-2
</code>{' '}
布局,左侧为标题小字,右侧为直接呈现的 Lucide 图标。
{t('spec.headerLayout2')}
</li>
<li>
数值强调使用且仅使用{' '}
{t('spec.valueStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-2xl font-semibold tracking-tight
</code>{' '}
展示。
{t('spec.valueStyle2')}
</li>
<li>
进度条使用{' '}
{t('spec.progressBar')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-1.5
</code>{' '}
细条高度,不喧宾夺主。
{t('spec.progressBar2')}
</li>
<li>
底部的次要说明信息必须统一使用{' '}
{t('spec.secondaryText')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-[10px] text-muted-foreground
</code>{' '}
等辅助类。
{t('spec.secondaryText2')}
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
@@ -4,6 +4,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -19,27 +20,28 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TableTab() {
const t = useTranslations('admin.demo.table');
const mockTableData = [
{
id: 'TX-1002',
name: '用户信息批量同步导出',
type: '数据同步',
name: t('mockData.syncExport'),
type: t('mockType.dataSync'),
status: 'success',
duration: '1.2s',
time: '2026-06-15 14:22',
},
{
id: 'TX-1003',
name: '系统数据库每周自动备份归档',
type: '数据库运维',
name: t('mockData.dbBackup'),
type: t('mockType.dbOps'),
status: 'running',
duration: '在执行中',
duration: t('mockDuration.running'),
time: '2026-06-15 15:00',
},
{
id: 'TX-1004',
name: '远端S3过期缓存对象自动清理任务',
type: '磁盘清理',
name: t('mockData.s3Cleanup'),
type: t('mockType.diskCleanup'),
status: 'failed',
duration: '0.4s',
time: '2026-06-15 10:05',
@@ -54,7 +56,7 @@ export function TableTab() {
<div className='relative w-64'>
<Search className='absolute left-2.5 top-2.5 size-3 text-muted-foreground' />
<Input
placeholder='输入任务名称搜索...'
placeholder={t('searchPlaceholder')}
className='h-8 pl-8 text-xs w-full shadow-none border-dashed bg-background'
/>
</div>
@@ -64,12 +66,12 @@ export function TableTab() {
className='h-8 border-dashed text-xs shadow-none'
>
<ListFilter className='size-3 mr-1' />
过滤
{t('filter')}
</Button>
</div>
<Button size='sm' className='h-8 text-xs shadow-none'>
<Plus className='size-3.5 mr-1' />
新建任务
{t('newTask')}
</Button>
</div>
@@ -79,25 +81,25 @@ export function TableTab() {
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='w-[100px] text-xs font-semibold'>
任务编号
{t('columns.taskId')}
</TableHead>
<TableHead className='text-xs font-semibold'>
执行任务名称
{t('columns.taskName')}
</TableHead>
<TableHead className='w-[120px] text-xs font-semibold'>
任务类型
{t('columns.taskType')}
</TableHead>
<TableHead className='w-[100px] text-xs font-semibold'>
状态
{t('columns.status')}
</TableHead>
<TableHead className='w-[100px] text-xs font-semibold'>
执行耗时
{t('columns.duration')}
</TableHead>
<TableHead className='w-[150px] text-xs font-semibold'>
触发时间
{t('columns.triggerTime')}
</TableHead>
<TableHead className='w-[80px] text-xs font-semibold text-right'>
操作
{t('columns.actions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -123,7 +125,7 @@ export function TableTab() {
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
成功
{t('status.success')}
</Badge>
)}
{row.status === 'running' && (
@@ -132,7 +134,7 @@ export function TableTab() {
className='text-[10px] bg-blue-500/10 border-blue-500/20 text-blue-600 rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-blue-500 rounded-full mr-1.5 shrink-0 animate-pulse' />
运行中
{t('status.running')}
</Badge>
)}
{row.status === 'failed' && (
@@ -141,7 +143,7 @@ export function TableTab() {
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2 font-medium'
>
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
失败
{t('status.failed')}
</Badge>
)}
</TableCell>
@@ -180,13 +182,13 @@ export function TableTab() {
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold flex items-center gap-1.5'>
<Code className='size-4 text-primary' />
表格设计规范与代码模板
{t('tableDesignSpec')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<ul className='list-disc pl-4 text-xs text-muted-foreground space-y-1.5'>
<li>
表格容器必须统一使用带有虚线边框的容器,即类名:
{t('spec.container')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border border-dashed shadow-none rounded-lg overflow-hidden
bg-background
@@ -194,40 +196,40 @@ export function TableTab() {
。
</li>
<li>
表头背景设为{' '}
{t('spec.headerBg')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
bg-muted/40
</code>
。表头表项字号统一为小字粗体{' '}
{t('spec.headerStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs font-semibold
</code>
。
</li>
<li>
单元格行与列之间使用虚线分割(
{t('spec.cellDivider')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed
</code>
)。表格行悬浮交互采用温和过渡色{' '}
{t('spec.rowHover')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
hover:bg-muted/10 transition-colors
</code>
。
</li>
<li>
编码、主键或唯一 ID 类字段一律采用等宽字体呈现:
{t('spec.monoFont')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-mono text-xs
</code>
。
</li>
<li>
状态标签(Badge)尽量圆角化(
{t('spec.badgeStyle')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
rounded-full py-0 px-2
</code>
),并以浅背景配以各自对应的图标圆点,以实现现代、高对比度的视觉质感。
{t('spec.badgeStyle2')}
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react';
import {
@@ -81,6 +82,7 @@ function formatDate(dateStr: string) {
}
export function FileList() {
const t = useTranslations('admin.files');
const queryClient = useQueryClient();
const [keyword, setKeyword] = React.useState('');
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
@@ -157,10 +159,10 @@ export function FileList() {
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['files', 'my'] });
void queryClient.invalidateQueries({ queryKey: ['files', 'stats'] });
toast.success('文件已删除');
toast.success(t('list.deleteSuccess'));
setDeleteTarget(null);
},
onError: (err: Error) => toast.error(err.message || '删除失败'),
onError: (err: Error) => toast.error(err.message || t('list.deleteFailed')),
});
// 批量 ZIP 下载
@@ -173,9 +175,9 @@ export function FileList() {
a.download = 'batch_download.zip';
a.click();
URL.revokeObjectURL(url);
toast.success('批量下载已开始');
toast.success(t('list.batchDownloadSuccess'));
},
onError: () => toast.error('批量下载失败'),
onError: () => toast.error(t('list.batchDownloadFailed')),
});
const toggleSelect = (id: string) => {
@@ -209,7 +211,7 @@ export function FileList() {
<div className='relative flex-1 max-w-sm'>
<Search className='absolute left-3 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground' />
<Input
placeholder='搜索文件名...'
placeholder={t('list.searchPlaceholder')}
className='pl-8 h-8 text-xs border-dashed rounded-lg focus-visible:ring-0'
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
@@ -232,7 +234,9 @@ export function FileList() {
selectedIds.size === files.length ? clearSelection : selectAll
}
>
{selectedIds.size === files.length ? '取消全选' : '全选本页'}
{selectedIds.size === files.length
? t('list.deselectAll')
: t('list.selectAll')}
</Button>
)}
@@ -247,7 +251,7 @@ export function FileList() {
className='flex items-center gap-2'
>
<Badge variant='secondary' className='text-xs px-2.5'>
已选 {selectedIds.size} 个
{t('list.selectedCount', { count: selectedIds.size })}
</Badge>
<Button
size='sm'
@@ -261,7 +265,7 @@ export function FileList() {
) : (
<FileArchive className='size-3.5 mr-1' />
)}
打包下载
{t('list.batchDownload')}
</Button>
<Button
size='sm'
@@ -278,7 +282,7 @@ export function FileList() {
{total > 0 && (
<span className='text-xs text-muted-foreground shrink-0'>
共 {total} 个文件
{t('list.totalFiles', { count: total })}
</span>
)}
</div>
@@ -292,7 +296,7 @@ export function FileList() {
<div className='flex flex-col items-center justify-center py-20 gap-4 text-muted-foreground'>
<Upload className='size-12 text-muted-foreground/30' />
<p className='text-sm'>
{debouncedKeyword ? '没有匹配的文件' : '您还没有上传任何文件'}
{debouncedKeyword ? t('list.noMatch') : t('list.noFiles')}
</p>
</div>
) : (
@@ -309,18 +313,28 @@ export function FileList() {
onCheckedChange={handleSelectAll}
/>
</TableHead>
<TableHead className='w-[80px] py-3'>预览</TableHead>
<TableHead className='w-[180px] py-3'>ID</TableHead>
<TableHead className='py-3'>文件名</TableHead>
<TableHead className='max-w-[200px] truncate py-3'>
路径
<TableHead className='w-[80px] py-3'>
{t('list.colPreview')}
</TableHead>
<TableHead className='w-[180px] py-3'>ID</TableHead>
<TableHead className='py-3'>{t('list.colName')}</TableHead>
<TableHead className='max-w-[200px] truncate py-3'>
{t('list.colPath')}
</TableHead>
<TableHead className='w-[100px] py-3'>
{t('list.colType')}
</TableHead>
<TableHead className='w-[125px] py-3'>
{t('list.colMime')}
</TableHead>
<TableHead className='w-[100px] py-3'>
{t('list.colSize')}
</TableHead>
<TableHead className='w-[150px] py-3'>
{t('list.colUploadedAt')}
</TableHead>
<TableHead className='w-[100px] py-3'>业务类别</TableHead>
<TableHead className='w-[125px] py-3'>MIME类型</TableHead>
<TableHead className='w-[100px] py-3'>大小</TableHead>
<TableHead className='w-[150px] py-3'>上传时间</TableHead>
<TableHead className='w-[120px] text-right pr-4 py-3'>
操作
{t('list.colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -396,7 +410,7 @@ export function FileList() {
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
title='查看详情'
title={t('list.viewDetail')}
onClick={() => setDetailTarget(file)}
>
<Eye className='size-3.5' />
@@ -405,7 +419,7 @@ export function FileList() {
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-muted'
title='下载'
title={t('list.download')}
onClick={() => handleDownload(file)}
>
<Download className='size-3.5' />
@@ -414,7 +428,7 @@ export function FileList() {
size='icon'
variant='ghost'
className='h-7 w-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
title='删除'
title={t('list.delete')}
onClick={() => setDeleteTarget(file)}
>
<Trash2 className='size-3.5' />
@@ -439,7 +453,7 @@ export function FileList() {
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
上一页
{t('list.prevPage')}
</Button>
<span className='text-xs text-muted-foreground'>
{page} / {totalPages}
@@ -451,7 +465,7 @@ export function FileList() {
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
{t('list.nextPage')}
</Button>
</div>
)}
@@ -463,10 +477,8 @@ export function FileList() {
>
<SheetContent className='w-full sm:max-w-md overflow-y-auto p-6'>
<SheetHeader className='border-b pb-4 px-0'>
<SheetTitle>文件详情</SheetTitle>
<SheetDescription>
查看文件的完整元数据与配置属性。
</SheetDescription>
<SheetTitle>{t('list.detailTitle')}</SheetTitle>
<SheetDescription>{t('list.detailDesc')}</SheetDescription>
</SheetHeader>
{detailTarget && (
@@ -495,7 +507,7 @@ export function FileList() {
<div className='space-y-4 text-xs'>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件 ID
{t('list.detailId')}
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.id}
@@ -504,7 +516,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件名
{t('list.detailName')}
</span>
<span className='col-span-2 break-all font-medium text-foreground/90'>
{detailTarget.file_name}
@@ -513,7 +525,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
存储路径
{t('list.detailPath')}
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.file_path}
@@ -522,17 +534,17 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
大小
{t('list.detailSize')}
</span>
<span className='col-span-2 text-foreground/90'>
{formatFileSize(detailTarget.file_size)} (
{detailTarget.file_size.toLocaleString()} 字节)
{detailTarget.file_size.toLocaleString()} {t('list.bytes')})
</span>
</div>
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
MIME 类型
{t('list.detailMime')}
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.mime_type}
@@ -541,7 +553,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
文件后缀
{t('list.detailExtension')}
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.extension}
@@ -559,7 +571,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
业务类型
{t('list.detailBizType')}
</span>
<span className='col-span-2'>
<Badge
@@ -573,7 +585,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
存储驱动
{t('list.detailDriver')}
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{storageDriverQuery.data ?? 'local'}
@@ -582,7 +594,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
上传者 ID
{t('list.detailUploaderId')}
</span>
<span className='col-span-2 font-mono text-foreground/90'>
{detailTarget.user_id}
@@ -591,7 +603,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
上传时间
{t('list.detailUploadedAt')}
</span>
<span className='col-span-2 text-foreground/90'>
{formatDate(detailTarget.created_at)}
@@ -602,7 +614,7 @@ export function FileList() {
Object.keys(detailTarget.metadata).length > 0 && (
<div className='space-y-1.5 pt-2'>
<span className='text-muted-foreground font-medium block'>
额外元数据
{t('list.detailMetadata')}
</span>
<pre className='p-3 bg-muted/40 border rounded-lg text-[10px] font-mono overflow-x-auto text-foreground/90 max-h-40 overflow-y-auto'>
{JSON.stringify(detailTarget.metadata, null, 2)}
@@ -622,18 +634,14 @@ export function FileList() {
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogTitle>{t('list.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{' '}
<span className='font-semibold text-foreground'>
「{deleteTarget?.file_name}」
</span>{' '}
吗?此操作不可撤销。
{t('list.deleteConfirm', { name: deleteTarget?.file_name || '' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{t('list.cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
@@ -645,7 +653,7 @@ export function FileList() {
{deleteMutation.isPending && (
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
)}
确认删除
{t('list.confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query';
import {
Database,
@@ -51,40 +52,41 @@ const categoryMap: Record<string, string> = {
const statsChartConfig = {
images: {
label: '图片',
label: 'images',
color: 'hsl(var(--chart-1))',
},
videos: {
label: '视频',
label: 'videos',
color: 'hsl(var(--chart-2))',
},
audio: {
label: '音频',
label: 'audio',
color: 'hsl(var(--chart-3))',
},
documents: {
label: '文档',
label: 'documents',
color: 'hsl(var(--chart-4))',
},
archives: {
label: '压缩包',
label: 'archives',
color: 'hsl(var(--chart-5))',
},
others: {
label: '其他',
label: 'others',
color: 'hsl(var(--chart-6))',
},
count: {
label: '新增文件数',
label: 'count',
color: 'hsl(var(--primary))',
},
size: {
label: '新增大小',
label: 'size',
color: 'hsl(var(--chart-2))',
},
} satisfies ChartConfig;
export function FileStats() {
const t = useTranslations('admin.files');
const [trendMetric, setTrendMetric] = React.useState<'count' | 'size'>(
'count',
);
@@ -182,9 +184,9 @@ export function FileStats() {
<div className='flex flex-col items-center justify-center py-24 border border-dashed rounded-xl bg-card text-muted-foreground gap-4'>
<Upload className='size-14 text-muted-foreground/25' />
<div className='text-center space-y-1'>
<p className='font-medium'>暂无上传数据</p>
<p className='font-medium'>{t('stats.noData')}</p>
<p className='text-xs text-muted-foreground'>
上传文件后即可在此查看容量与文件分布分析。
{t('stats.noDataDesc')}
</p>
</div>
</div>
@@ -198,7 +200,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
总文件数
{t('stats.totalFiles')}
</CardTitle>
<Files className='size-4 text-sky-500' />
</CardHeader>
@@ -206,11 +208,11 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-sky-500'>
{stats.total_count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
个
{t('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
您上传的可用文件数量汇总
{t('stats.totalFilesDesc')}
</p>
</CardContent>
</Card>
@@ -218,7 +220,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
占用存储容量
{t('stats.totalSize')}
</CardTitle>
<Database className='size-4 text-emerald-500' />
</CardHeader>
@@ -227,7 +229,7 @@ export function FileStats() {
{formatFileSize(stats.total_size)}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
文件实际在磁盘/S3中占用的总空间
{t('stats.totalSizeDesc')}
</p>
</CardContent>
</Card>
@@ -235,7 +237,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
近 7 天新增文件
{t('stats.weeklyNewFiles')}
</CardTitle>
<TrendingUp className='size-4 text-indigo-500' />
</CardHeader>
@@ -243,11 +245,11 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-indigo-500'>
+{trendSummary.count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
个
{t('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
最近 7 天上传成功的文件总数
{t('stats.weeklyNewFilesDesc')}
</p>
</CardContent>
</Card>
@@ -255,7 +257,7 @@ export function FileStats() {
<Card className='bg-card/25 border-border/40 hover:shadow-md transition-shadow'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-xs font-semibold tracking-tight text-muted-foreground'>
近 7 天新增大小
{t('stats.weeklyNewSize')}
</CardTitle>
<HardDrive className='size-4 text-amber-500' />
</CardHeader>
@@ -264,7 +266,7 @@ export function FileStats() {
+{formatFileSize(trendSummary.size)}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
最近 7 天上传所消耗的存储带宽
{t('stats.weeklyNewSizeDesc')}
</p>
</CardContent>
</Card>
@@ -275,10 +277,10 @@ export function FileStats() {
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-sm font-bold'>
最近 7 天新增趋势
{t('stats.trendTitle')}
</CardTitle>
<CardDescription className='text-xs'>
按日统计近一周期内的上传波动
{t('stats.trendDesc')}
</CardDescription>
</div>
<div className='flex items-center gap-1 border rounded-lg p-0.5 bg-muted/30'>
@@ -288,7 +290,7 @@ export function FileStats() {
className='h-7 text-xs px-2.5 rounded-md'
onClick={() => setTrendMetric('count')}
>
新增数量 (个)
{t('stats.trendCount')}
</Button>
<Button
size='sm'
@@ -296,7 +298,7 @@ export function FileStats() {
className='h-7 text-xs px-2.5 rounded-md'
onClick={() => setTrendMetric('size')}
>
新增大小 (MB)
{t('stats.trendSize')}
</Button>
</div>
</CardHeader>
@@ -361,7 +363,9 @@ export function FileStats() {
hideLabel
formatter={(value) => {
const label =
trendMetric === 'count' ? '新增文件数' : '新增大小';
trendMetric === 'count'
? t('stats.chartLabelCount')
: t('stats.chartLabelSize');
const formattedValue =
trendMetric === 'size'
? formatFileSize(Number(value))
@@ -419,16 +423,16 @@ export function FileStats() {
<Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader>
<CardTitle className='text-sm font-bold'>
文件格式数量分布
{t('stats.countDistTitle')}
</CardTitle>
<CardDescription className='text-xs'>
各种格式类型的文件个数占比
{t('stats.countDistDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1 flex flex-col justify-between gap-4'>
{categoryCountData.length === 0 ? (
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
暂无分类数据
{t('stats.noCategoryData')}
</div>
) : (
<div className='h-[240px] w-full'>
@@ -494,15 +498,10 @@ export function FileStats() {
<div className='bg-sky-500/5 border border-sky-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-sky-600 dark:text-sky-400'>
<Info className='size-4 shrink-0 mt-0.5' />
<p className='leading-normal'>
文件类型中数量最多的是{' '}
<strong className='font-bold'>
「{maxStats.maxCountName}」
</strong>
,累计上传了{' '}
<strong className='font-mono font-bold'>
{maxStats.maxCount}
</strong>{' '}
个文件。
{t('stats.maxCountHint', {
name: maxStats.maxCountName,
count: maxStats.maxCount,
})}
</p>
</div>
</CardContent>
@@ -512,16 +511,16 @@ export function FileStats() {
<Card className='bg-card/20 border-border/40 flex flex-col'>
<CardHeader>
<CardTitle className='text-sm font-bold'>
文件占用容量分布
{t('stats.sizeDistTitle')}
</CardTitle>
<CardDescription className='text-xs'>
各种格式类型的文件大小占比
{t('stats.sizeDistDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1 flex flex-col justify-between gap-4'>
{categorySizeData.length === 0 ? (
<div className='h-[240px] flex items-center justify-center text-xs text-muted-foreground'>
暂无容量数据
{t('stats.noSizeData')}
</div>
) : (
<div className='h-[240px] w-full'>
@@ -587,15 +586,10 @@ export function FileStats() {
<div className='bg-emerald-500/5 border border-emerald-500/10 rounded-lg p-3 flex items-start gap-2.5 text-xs text-emerald-600 dark:text-emerald-400'>
<Info className='size-4 shrink-0 mt-0.5' />
<p className='leading-normal'>
文件类型中占用存储最大的是{' '}
<strong className='font-bold'>
「{maxStats.maxSizeName}」
</strong>
,共消耗了{' '}
<strong className='font-mono font-bold'>
{formatFileSize(maxStats.maxSize)}
</strong>{' '}
存储容量。
{t('stats.maxSizeHint', {
name: maxStats.maxSizeName,
size: formatFileSize(maxStats.maxSize),
})}
</p>
</div>
</CardContent>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Database, Loader2, Play, Save } from 'lucide-react';
import { toast } from 'sonner';
@@ -44,13 +45,13 @@ import type {
const storageConfigKey = 'storage_config';
const storageMigrationTaskType = 'storage_migration';
const driverLabels: Record<StorageDriver, string> = {
local: '本地文件系统',
s3: 'AWS S3',
r2: 'Cloudflare R2',
minio: 'MinIO',
oss: '阿里云 OSS',
webdav: 'WebDAV',
const driverLabelKeys: Record<StorageDriver, string> = {
local: 'driverLocal',
s3: 'driverS3',
r2: 'driverR2',
minio: 'driverMinio',
oss: 'driverOss',
webdav: 'driverWebdav',
};
const emptyObjectConfig: ObjectStorageConfig = {
@@ -112,8 +113,8 @@ function parseMigrationPayload(payload: string): StorageConfig | undefined {
}
}
function driverLabel(driver?: StorageDriver): string {
return driver ? driverLabels[driver] : '未知';
function driverLabelKey(driver?: StorageDriver): string {
return driver ? driverLabelKeys[driver] : 'driverUnknown';
}
function latestMigration(
@@ -136,6 +137,7 @@ function latestMigration(
}
export function StorageConfigTab() {
const t = useTranslations('admin.files');
const queryClient = useQueryClient();
const [config, setConfig] = React.useState<StorageConfig | null>(null);
const query = useQuery({
@@ -186,13 +188,13 @@ export function StorageConfigTab() {
});
},
onSuccess: () => {
toast.success('存储配置已保存');
toast.success(t('storage.saveSuccess'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => {
toast.error(error.message || '保存存储配置失败');
toast.error(error.message || t('storage.saveFailed'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
@@ -208,13 +210,13 @@ export function StorageConfigTab() {
});
},
onSuccess: () => {
toast.success('存储迁移任务已下发');
toast.success(t('storage.migrateSuccess'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => {
toast.error(error.message || '下发存储迁移任务失败');
toast.error(error.message || t('storage.migrateFailed'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
@@ -224,12 +226,13 @@ export function StorageConfigTab() {
mutationFn: (executionID: string) =>
services.adminTask.retryTaskExecution(executionID),
onSuccess: () => {
toast.success('存储迁移任务已重新下发');
toast.success(t('storage.retrySuccess'));
void queryClient.invalidateQueries({
queryKey: ['admin', 'storage-config'],
});
},
onError: (error: Error) => toast.error(error.message || '运行存储迁移失败'),
onError: (error: Error) =>
toast.error(error.message || t('storage.retryFailed')),
});
if (query.isPending || !config) {
@@ -269,7 +272,7 @@ export function StorageConfigTab() {
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2'>
存储维护模式
{t('storage.maintenanceMode')}
<Badge
variant={
migration.state === 'failed' ? 'destructive' : 'secondary'
@@ -279,9 +282,10 @@ export function StorageConfigTab() {
</Badge>
</CardTitle>
<CardDescription>
{driverLabel(migration.source_driver)} →{' '}
{driverLabel(migration.target_driver)}
。迁移期间文件只允许读取,禁止上传、删除和清理。
{t('storage.maintenanceDesc', {
source: t(`storage.${driverLabelKey(migration.source_driver)}`),
target: t(`storage.${driverLabelKey(migration.target_driver)}`),
})}
</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-3'>
@@ -289,7 +293,9 @@ export function StorageConfigTab() {
value={migration.state === 'succeeded' ? 100 : undefined}
/>
<p className='text-sm text-muted-foreground'>
迁移进度请查看任务执行日志:{migration.task_id || '尚未下发'}
{t('storage.migrationProgress', {
taskId: migration.task_id || t('storage.notDispatched'),
})}
</p>
{migration.error && (
<p className='text-sm text-destructive'>{migration.error}</p>
@@ -310,7 +316,7 @@ export function StorageConfigTab() {
) : (
<Play data-icon='inline-start' />
)}
重试迁移
{t('storage.retryMigration')}
</Button>
</CardFooter>
</Card>
@@ -324,10 +330,10 @@ export function StorageConfigTab() {
</div>
<div>
<CardTitle className='text-base font-semibold'>
文件存储
{t('storage.fileStorage')}
</CardTitle>
<CardDescription className='text-xs'>
默认使用本地存储。配置系统文件的存储媒介,切换存储类型且已有文件时,系统会自动进入维护模式并迁移文件。
{t('storage.fileStorageDesc')}
</CardDescription>
</div>
</div>
@@ -337,7 +343,7 @@ export function StorageConfigTab() {
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<Field className='flex flex-col gap-1.5 md:col-span-2'>
<FieldLabel className='text-xs font-semibold'>
存储类型
{t('storage.storageType')}
</FieldLabel>
<Select
value={config.driver}
@@ -351,10 +357,10 @@ export function StorageConfigTab() {
</SelectTrigger>
<SelectContent>
<SelectGroup>
{(Object.keys(driverLabels) as StorageDriver[]).map(
{(Object.keys(driverLabelKeys) as StorageDriver[]).map(
(driver) => (
<SelectItem key={driver} value={driver}>
{driverLabels[driver]}
{t(`storage.${driverLabelKeys[driver]}`)}
</SelectItem>
),
)}
@@ -366,7 +372,7 @@ export function StorageConfigTab() {
{config.driver === 'local' && (
<div className='md:col-span-2'>
<TextField
label='根目录'
label={t('storage.rootDir')}
value={config.local.root}
placeholder='.'
onChange={(root) =>
@@ -396,7 +402,7 @@ export function StorageConfigTab() {
<>
<div className='md:col-span-2'>
<TextField
label='服务地址'
label={t('storage.endpoint')}
value={config.webdav.endpoint}
placeholder='https://dav.example.com'
onChange={(endpoint) =>
@@ -408,7 +414,7 @@ export function StorageConfigTab() {
/>
</div>
<TextField
label='用户名'
label={t('storage.username')}
value={config.webdav.username}
onChange={(username) =>
setConfig({
@@ -418,7 +424,7 @@ export function StorageConfigTab() {
}
/>
<TextField
label='密码'
label={t('storage.password')}
type='password'
value={config.webdav.password}
onChange={(password) =>
@@ -430,7 +436,7 @@ export function StorageConfigTab() {
/>
<div className='md:col-span-2'>
<TextField
label='基础路径'
label={t('storage.basePath')}
value={config.webdav.base_path}
placeholder='wavelet'
onChange={(base_path) =>
@@ -449,8 +455,7 @@ export function StorageConfigTab() {
<CardFooter className='justify-end gap-2 flex-col sm:flex-row items-end sm:items-center border-t border-dashed pt-4 mt-6'>
{config.driver !== query.data?.config.driver && (
<span className='text-xs text-amber-500 mr-auto text-left max-w-md'>
⚠️
您已切换存储类型。点击「保存配置」将立即切换活动存储引擎,并同步更新已有文件的存储驱动标记。仅在需要复制物理文件时才使用「开始迁移」。
{t('storage.switchWarning')}
</span>
)}
<div className='flex gap-2'>
@@ -465,7 +470,7 @@ export function StorageConfigTab() {
) : (
<Save data-icon='inline-start' />
)}
保存配置
{t('storage.saveConfig')}
</Button>
{config.driver !== query.data?.config.driver && (
<Button
@@ -477,7 +482,7 @@ export function StorageConfigTab() {
) : (
<Play data-icon='inline-start' />
)}
开始迁移
{t('storage.startMigration')}
</Button>
)}
</div>
@@ -496,6 +501,7 @@ function ObjectFields({
value: ObjectStorageConfig;
onChange: (patch: Partial<ObjectStorageConfig>) => void;
}) {
const t = useTranslations('admin.files');
return (
<>
{driver === 'r2' && (
@@ -539,13 +545,13 @@ function ObjectFields({
onChange={(secret_access_key) => onChange({ secret_access_key })}
/>
<TextField
label='对象前缀'
label={t('storage.keyPrefix')}
value={value.key_prefix}
placeholder='uploads'
onChange={(key_prefix) => onChange({ key_prefix })}
/>
<TextField
label='CDN 地址'
label={t('storage.cdnUrl')}
value={value.cdn_url}
placeholder='https://cdn.example.com'
onChange={(cdn_url) => onChange({ cdn_url })}
@@ -561,7 +567,7 @@ function ObjectFields({
Path Style
</FieldLabel>
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
MinIO 等自托管 S3 通常需要开启。
{t('storage.pathStyleDesc')}
</FieldDescription>
</div>
<Switch
+8 -4
View File
@@ -3,6 +3,7 @@
import * as React from 'react';
import { motion } from 'motion/react';
import { FolderOpen } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FileStats } from './components/file-stats';
@@ -10,6 +11,7 @@ import { FileList } from './components/file-list';
import { StorageConfigTab } from './components/storage-config-tab';
export default function FilesPage() {
const t = useTranslations('admin.files');
const [activeTab, setActiveTab] = React.useState('stats');
return (
@@ -23,7 +25,9 @@ export default function FilesPage() {
<div className='flex items-center gap-2'>
<FolderOpen className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>存储管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
@@ -33,16 +37,16 @@ export default function FilesPage() {
value='stats'
className='px-0 pb-2 text-xs font-semibold'
>
文件存储信息
{t('tabStats')}
</TabsTrigger>
<TabsTrigger value='list' className='px-0 pb-2 text-xs font-semibold'>
文件列表
{t('tabList')}
</TabsTrigger>
<TabsTrigger
value='storage'
className='px-0 pb-2 text-xs font-semibold'
>
存储配置
{t('tabStorage')}
</TabsTrigger>
</TabsList>
@@ -28,6 +28,7 @@ import {
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -83,6 +84,7 @@ function formatDateLabel(dateStr: string) {
}
export function AccessAnalytics() {
const t = useTranslations('admin.logs.analytics');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [clickhouseDisabled, setClickhouseDisabled] = useState(false);
@@ -135,11 +137,9 @@ export function AccessAnalytics() {
return (
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
<XCircle className='size-10 text-muted-foreground mb-3' />
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
<h3 className='text-base font-semibold'>{t('clickhouseDisabled')}</h3>
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
{t('clickhouseDisabledDesc')}
</p>
</div>
);
@@ -153,7 +153,7 @@ export function AccessAnalytics() {
return (
<LoadingStateWithBorder
icon={BarChart3}
description='加载访问统计指标中...'
description={t('loadingAnalytics')}
/>
);
}
@@ -165,7 +165,7 @@ export function AccessAnalytics() {
<Card className='bg-card/25 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
近 7 天总请求数
{t('totalRequests7d')}
</CardTitle>
<TrendingUp className='size-4 text-muted-foreground' />
</CardHeader>
@@ -174,14 +174,14 @@ export function AccessAnalytics() {
{totalTrendRequests.toLocaleString()}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
系统记录的所有成功认证的访问总频次
{t('totalRequests7dDesc')}
</p>
</CardContent>
</Card>
<Card className='bg-card/25 border-border/40'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
活跃终端分类数
{t('activeBrowserTypes')}
</CardTitle>
<Globe className='size-4 text-muted-foreground' />
</CardHeader>
@@ -190,14 +190,14 @@ export function AccessAnalytics() {
{browsers.length}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
在一周内发起请求的浏览器代理大类统计
{t('activeBrowserTypesDesc')}
</p>
</CardContent>
</Card>
<Card className='bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1'>
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<CardTitle className='text-sm font-semibold tracking-tight'>
活跃独立用户数
{t('activeUsers')}
</CardTitle>
<Users className='size-4 text-muted-foreground' />
</CardHeader>
@@ -206,7 +206,7 @@ export function AccessAnalytics() {
{topUsers.length}
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
一周内累计发起高频请求的注册账户总量
{t('activeUsersDesc')}
</p>
</CardContent>
</Card>
@@ -217,10 +217,10 @@ export function AccessAnalytics() {
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base font-bold'>
一周访问量趋势
{t('weeklyAccessTrend')}
</CardTitle>
<CardDescription className='text-xs'>
展现系统最近 7 天内的每日 API 请求曲线
{t('weeklyAccessTrendDesc')}
</CardDescription>
</div>
<Button
@@ -241,7 +241,7 @@ export function AccessAnalytics() {
{trend.length === 0 ? (
<EmptyStateWithBorder
icon={TrendingUp}
description='暂无趋势数据'
description={t('noTrendData')}
/>
) : (
<div className='h-[280px] w-full'>
@@ -305,17 +305,17 @@ export function AccessAnalytics() {
<CardHeader>
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Globe className='size-4.5 text-muted-foreground' />
使用的浏览器排行
{t('browserRanking')}
</CardTitle>
<CardDescription className='text-xs'>
基于请求头 User-Agent 智能分类的一周占比统计
{t('browserRankingDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1'>
{browsers.length === 0 ? (
<EmptyStateWithBorder
icon={Globe}
description='暂无浏览器排行数据'
description={t('noBrowserData')}
/>
) : (
<div className='space-y-4'>
@@ -360,17 +360,17 @@ export function AccessAnalytics() {
<CardHeader>
<CardTitle className='text-base font-bold flex items-center gap-1.5'>
<Users className='size-4.5 text-muted-foreground' />
最活跃的用户排行 (Top 10)
{t('topActiveUsers')}
</CardTitle>
<CardDescription className='text-xs'>
统计最近一周发起接口访问请求数量最多的账户
{t('topActiveUsersDesc')}
</CardDescription>
</CardHeader>
<CardContent className='flex-1'>
{topUsers.length === 0 ? (
<EmptyStateWithBorder
icon={Users}
description='暂无活跃用户数据'
description={t('noActiveUsersData')}
/>
) : (
<div className='divide-y divide-border/40'>
@@ -385,11 +385,13 @@ export function AccessAnalytics() {
</div>
<div className='truncate max-w-[180px] sm:max-w-xs'>
<div className='text-xs font-bold leading-tight truncate'>
{user.username || '未知'}
{user.username || t('unknown')}
</div>
<div className='text-[10px] text-muted-foreground leading-normal truncate'>
{user.nickname ? `(${user.nickname})` : '(无昵称)'} |
ID: {user.user_id}
{user.nickname
? `(${user.nickname})`
: t('noNickname')}{' '}
| ID: {user.user_id}
</div>
</div>
</div>
@@ -398,7 +400,7 @@ export function AccessAnalytics() {
{user.count.toLocaleString()}
</div>
<div className='text-[9px] text-muted-foreground uppercase font-bold tracking-wider'>
次请求
{t('requests')}
</div>
</div>
</div>
@@ -32,6 +32,7 @@ import {
} from 'lucide-react';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -97,6 +98,7 @@ function isClickhouseDisabledError(error: Error) {
}
export function AccessLogs() {
const t = useTranslations('admin.logs.accessLogs');
const [page, setPage] = useState(1);
// 搜索过滤条件
@@ -202,11 +204,9 @@ export function AccessLogs() {
return (
<div className='flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]'>
<XCircle className='size-10 text-muted-foreground mb-3' />
<h3 className='text-base font-semibold'>ClickHouse 未启用</h3>
<h3 className='text-base font-semibold'>{t('clickhouseDisabled')}</h3>
<p className='text-sm text-muted-foreground mt-1 max-w-[400px] mb-4'>
当前系统配置未启用 ClickHouse
存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml`
配置文件中配置并启用 ClickHouse。
{t('clickhouseDisabledDesc')}
</p>
</div>
);
@@ -227,11 +227,11 @@ export function AccessLogs() {
>
<div className='grid gap-1.5'>
<Label htmlFor='search-user' className='text-xs'>
用户名
{t('username')}
</Label>
<Input
id='search-user'
placeholder='输入用户名搜索...'
placeholder={t('usernamePlaceholder')}
value={usernameFilter}
onChange={(e) => setUsernameFilter(e.target.value)}
className='h-8 text-xs'
@@ -239,11 +239,11 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-path' className='text-xs'>
接口路径
{t('apiPath')}
</Label>
<Input
id='search-path'
placeholder='模糊匹配路径...'
placeholder={t('pathPlaceholder')}
value={pathFilter}
onChange={(e) => setPathFilter(e.target.value)}
className='h-8 text-xs'
@@ -251,7 +251,7 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-start' className='text-xs'>
起始时间
{t('startTime')}
</Label>
<Input
id='search-start'
@@ -263,7 +263,7 @@ export function AccessLogs() {
</div>
<div className='grid gap-1.5'>
<Label htmlFor='search-end' className='text-xs'>
结束时间
{t('endTime')}
</Label>
<Input
id='search-end'
@@ -276,7 +276,7 @@ export function AccessLogs() {
<div className='flex gap-2'>
<Button type='submit' size='sm' className='h-8 flex-1'>
<Search className='size-3.5 mr-1' />
搜索
{t('search')}
</Button>
<Button
type='button'
@@ -286,7 +286,7 @@ export function AccessLogs() {
className='h-8 flex-1'
>
<RotateCcw className='size-3.5 mr-1' />
重置
{t('reset')}
</Button>
</div>
</form>
@@ -295,26 +295,27 @@ export function AccessLogs() {
{loading && logs.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载访问日志中...'
description={t('loadingAccessLogs')}
/>
) : logs.length === 0 ? (
<EmptyStateWithBorder
icon={Activity}
description='未查询到任何访问日志数据'
/>
<EmptyStateWithBorder icon={Activity} description={t('noAccessLogs')} />
) : (
<div className='rounded-lg border bg-card overflow-hidden'>
<Table className='min-w-[900px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[100px]'>请求方法</TableHead>
<TableHead className='min-w-[200px]'>路径</TableHead>
<TableHead className='w-[150px]'>用户</TableHead>
<TableHead className='w-[120px]'>IP</TableHead>
<TableHead className='w-[90px]'>状态</TableHead>
<TableHead className='w-[100px]'>耗时</TableHead>
<TableHead className='w-[170px]'>请求时间</TableHead>
<TableHead className='w-[80px] text-center'>详情</TableHead>
<TableHead className='w-[100px]'>{t('colMethod')}</TableHead>
<TableHead className='min-w-[200px]'>{t('colPath')}</TableHead>
<TableHead className='w-[150px]'>{t('colUser')}</TableHead>
<TableHead className='w-[120px]'>{t('colIp')}</TableHead>
<TableHead className='w-[90px]'>{t('colStatus')}</TableHead>
<TableHead className='w-[100px]'>{t('colLatency')}</TableHead>
<TableHead className='w-[170px]'>
{t('colRequestTime')}
</TableHead>
<TableHead className='w-[80px] text-center'>
{t('colDetail')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -334,7 +335,7 @@ export function AccessLogs() {
<TableCell>
<div className='flex flex-col'>
<span className='text-xs font-medium'>
{log.username || '未知用户'}
{log.username || t('unknownUser')}
</span>
{log.nickname && (
<span className='text-[10px] text-muted-foreground'>
@@ -381,7 +382,7 @@ export function AccessLogs() {
{logs.length > 0 && (
<div className='flex items-center justify-between py-1'>
<div className='text-xs text-muted-foreground'>
共 {total} 条记录,当前第 {page}/{totalPages} 页
{t('paginationInfo', { total, page, totalPages })}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -392,7 +393,7 @@ export function AccessLogs() {
className='h-8 text-xs'
>
<ChevronLeft className='size-3.5 mr-1' />
上一页
{t('prevPage')}
</Button>
<Button
variant='outline'
@@ -401,7 +402,7 @@ export function AccessLogs() {
disabled={page >= totalPages || loading}
className='h-8 text-xs'
>
下一页
{t('nextPage')}
<ChevronRight className='size-3.5 ml-1' />
</Button>
</div>
@@ -412,7 +413,9 @@ export function AccessLogs() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l'>
<SheetHeader className='border-b px-5 py-4 shrink-0'>
<SheetTitle className='text-lg font-bold'>访问日志详情</SheetTitle>
<SheetTitle className='text-lg font-bold'>
{t('accessLogDetail')}
</SheetTitle>
<SheetDescription className='text-xs'>
访问 ID: {selectedLog?.id}
</SheetDescription>
@@ -424,7 +427,7 @@ export function AccessLogs() {
<div className='grid grid-cols-2 gap-3 sm:grid-cols-3'>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
请求方法
{t('method')}
</div>
<div className='mt-1 font-mono text-sm font-bold uppercase'>
{selectedLog.method}
@@ -432,7 +435,7 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
响应状态
{t('responseStatus')}
</div>
<div className='mt-1'>
<Badge variant={statusVariant(selectedLog.status)}>
@@ -442,7 +445,7 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
耗时
{t('latency')}
</div>
<div className='mt-1 font-mono text-sm'>
{formatLatency(selectedLog.latency)}
@@ -450,18 +453,18 @@ export function AccessLogs() {
</div>
<div className='rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
IP 地址
{t('ipAddress')}
</div>
<div className='mt-1 font-mono text-sm'>{selectedLog.ip}</div>
</div>
<div className='rounded-lg border p-3 bg-muted/10 col-span-2'>
<div className='text-[10px] text-muted-foreground uppercase font-bold tracking-wider'>
用户
{t('user')}
</div>
<div className='mt-1 text-sm font-medium'>
{selectedLog.username
? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})`
: '未知/游客'}
? `${selectedLog.username} (${selectedLog.nickname || t('noNickname')})`
: t('unknownGuest')}
<span className='block font-mono text-[10px] text-muted-foreground mt-0.5'>
ID: {selectedLog.user_id}
</span>
@@ -472,7 +475,7 @@ export function AccessLogs() {
{/* Path */}
<div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'>
请求路径
{t('requestPath')}
</Label>
<div className='rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all'>
{selectedLog.path}
@@ -482,7 +485,7 @@ export function AccessLogs() {
{/* Request Time */}
<div className='grid gap-2'>
<Label className='text-xs font-bold text-muted-foreground'>
请求时间
{t('requestTime')}
</Label>
<div className='font-mono text-xs'>
{formatDateTime(selectedLog.created_at)}
@@ -542,7 +545,7 @@ export function AccessLogs() {
<div className='flex-grow flex items-center justify-center'>
<EmptyStateWithBorder
icon={Activity}
description='未选中日志明细'
description={t('noLogSelected')}
/>
</div>
)}
@@ -22,6 +22,7 @@ import { toast } from 'sonner';
import { ArrowDown, ChevronUp, Loader2 } from 'lucide-react';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
@@ -92,6 +93,7 @@ function parseLogLevel(
}
export function AppLogs() {
const t = useTranslations('admin.logs.appLogs');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
@@ -300,12 +302,12 @@ export function AppLogs() {
{loadingMore ? (
<>
<Loader2 className='size-3 mr-1.5 animate-spin' />
加载中...
{t('loading')}
</>
) : (
<>
<ChevronUp className='size-3 mr-1.5' />
加载更早日志
{t('loadOlderLogs')}
</>
)}
</Button>
@@ -313,7 +315,9 @@ export function AppLogs() {
)}
{logs.length === 0 ? (
<div className='px-4 py-8 text-center text-gray-500'>暂无日志</div>
<div className='px-4 py-8 text-center text-gray-500'>
{t('noLogs')}
</div>
) : (
<div
className='relative px-3 py-3'
@@ -349,7 +353,7 @@ export function AppLogs() {
className='shadow-lg bg-background/80 backdrop-blur-sm border-border/50'
>
<ArrowDown className='size-3.5 mr-1.5' />
回到最新
{t('backToLatest')}
</Button>
</div>
)}
@@ -2,6 +2,7 @@
import dynamic from 'next/dynamic';
import { Activity, BarChart3, Terminal } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -26,13 +27,16 @@ const AppLogs = dynamic(
);
export function LogsPageClient() {
const t = useTranslations('admin.logs');
return (
<div className='flex flex-col h-full space-y-6 py-6'>
{/* Header */}
<div className='flex items-center gap-2'>
<Terminal className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>系统日志</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
{/* Tabs Layout */}
@@ -43,21 +47,21 @@ export function LogsPageClient() {
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<BarChart3 className='size-3.5' />
访问分析
{t('accessAnalytics')}
</TabsTrigger>
<TabsTrigger
value='access'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<Activity className='size-3.5' />
用户访问日志
{t('userAccessLogs')}
</TabsTrigger>
<TabsTrigger
value='app'
className='px-0 pb-2 text-xs font-semibold flex items-center gap-1.5'
>
<Terminal className='size-3.5' />
应用运行日志
{t('appRuntimeLogs')}
</TabsTrigger>
</TabsList>
@@ -57,6 +57,8 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { useTranslations } from 'next-intl';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -70,6 +72,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function EventsTab() {
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
// --- 获取所有自定义消息通道 ---
@@ -107,12 +110,12 @@ export function EventsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) =>
PushService.updateEvent(id, data),
onSuccess: () => {
toast.success('事件更新成功');
toast.success(t('eventUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setEditEventOpen(false);
},
onError: (err: unknown) => {
toast.error('事件更新失败: ' + (err as Error).message);
toast.error(t('eventUpdateFailed') + ': ' + (err as Error).message);
},
});
@@ -122,14 +125,14 @@ export function EventsTab() {
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
onError: (err: unknown) => {
toast.error('操作失败: ' + (err as Error).message);
toast.error(t('operationFailed') + ': ' + (err as Error).message);
},
});
const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
onSuccess: () => {
toast.success('事件创建成功');
toast.success(t('eventCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
setCreateEventOpen(false);
setNewEventKey('');
@@ -139,18 +142,18 @@ export function EventsTab() {
setNewEventEnabled(true);
},
onError: (err: unknown) => {
toast.error('事件创建失败: ' + (err as Error).message);
toast.error(t('eventCreateFailed') + ': ' + (err as Error).message);
},
});
const deleteEventMutation = useMutation({
mutationFn: (id: number) => PushService.deleteEvent(id),
onSuccess: () => {
toast.success('配置删除成功');
toast.success(t('configDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-events'] });
},
onError: (err: unknown) => {
toast.error('配置删除失败: ' + (err as Error).message);
toast.error(t('configDeleteFailed') + ': ' + (err as Error).message);
},
});
@@ -198,7 +201,7 @@ export function EventsTab() {
try {
JSON.parse(eventTemplate);
} catch {
toast.error('消息模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
@@ -258,11 +261,11 @@ export function EventsTab() {
const handleCreateEvent = () => {
if (newEventType === 'builtin' && !newEventKey) {
toast.error('请选择系统事件');
toast.error(t('selectSystemEvent'));
return;
}
if (newEventType === 'task' && !newEventTaskType) {
toast.error('请选择异步任务');
toast.error(t('selectAsyncTask'));
return;
}
@@ -270,7 +273,7 @@ export function EventsTab() {
try {
JSON.parse(newEventTemplate);
} catch {
toast.error('内容渲染模板不是合法的 JSON 格式');
toast.error(t('templateInvalidJson'));
return;
}
}
@@ -295,11 +298,14 @@ export function EventsTab() {
<div className='flex justify-end'>
<Button size='sm' onClick={handleCreateEventClick} className='text-xs'>
<Plus className='size-3.5 mr-1' />
新增通知事件
{t('addEvent')}
</Button>
</div>
{eventsQuery.isLoading ? (
<LoadingStateWithBorder icon={Layers} description='加载通知事件中...' />
<LoadingStateWithBorder
icon={Layers}
description={t('loadingEvents')}
/>
) : eventsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'>
<ErrorInline
@@ -309,10 +315,7 @@ export function EventsTab() {
/>
</div>
) : (eventsQuery.data ?? []).length === 0 ? (
<EmptyStateWithBorder
icon={Layers}
description='暂无通知配置事件,请点击右上角新增'
/>
<EmptyStateWithBorder icon={Layers} description={t('noEvents')} />
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<Table className='w-full caption-bottom text-sm min-w-full'>
@@ -322,19 +325,19 @@ export function EventsTab() {
ID
</TableHead>
<TableHead className='w-[180px] whitespace-nowrap py-2 h-8'>
通知事件
{t('colEvent')}
</TableHead>
<TableHead className='w-[200px] whitespace-nowrap py-2 h-8'>
关联渠道
{t('colChannels')}
</TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'>
推送目标
{t('colTargets')}
</TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态
{t('colStatus')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -362,7 +365,7 @@ export function EventsTab() {
variant='outline'
className='text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200'
>
任务
{t('taskBadge')}
</Badge>
)}
</div>
@@ -375,7 +378,7 @@ export function EventsTab() {
<div className='flex flex-wrap gap-1'>
{(event.channels ?? []).length === 0 ? (
<span className='text-xs text-muted-foreground italic'>
未指定渠道
{t('noChannelSpecified')}
</span>
) : (
event.channels.map((ch) => (
@@ -384,7 +387,7 @@ export function EventsTab() {
variant='secondary'
className='text-[10px] py-0 px-1.5 h-4.5'
>
{ch === 'email' ? '邮件' : ch}
{ch === 'email' ? t('emailChannel') : ch}
</Badge>
))
)}
@@ -439,7 +442,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
配置
{t('configure')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -453,7 +456,7 @@ export function EventsTab() {
className='h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10'
disabled={deleteEventMutation.isPending}
onClick={() => {
if (confirm('确定要删除该通知事件配置吗?')) {
if (confirm(t('deleteEventConfirm'))) {
deleteEventMutation.mutate(event.id);
}
}}
@@ -462,7 +465,7 @@ export function EventsTab() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
删除
{t('delete')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -479,15 +482,13 @@ export function EventsTab() {
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>新增通知事件</DialogTitle>
<DialogDescription>
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
</DialogDescription>
<DialogTitle>{t('addEventDialogTitle')}</DialogTitle>
<DialogDescription>{t('addEventDialogDesc')}</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>事件类型</Label>
<Label className='text-xs font-semibold'>{t('eventType')}</Label>
<div className='flex gap-4 p-1.5 border rounded-md bg-muted/20'>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input
@@ -501,7 +502,7 @@ export function EventsTab() {
}}
className='scale-90'
/>
<span>系统内置事件</span>
<span>{t('builtinEventType')}</span>
</label>
<label className='flex items-center gap-1.5 text-xs cursor-pointer font-medium'>
<input
@@ -515,22 +516,24 @@ export function EventsTab() {
}}
className='scale-90'
/>
<span>任务完成事件</span>
<span>{t('taskEventType')}</span>
</label>
</div>
</div>
{newEventType === 'builtin' ? (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统事件</Label>
<Label className='text-xs font-semibold'>
{t('systemEvent')}
</Label>
{builtInEventsQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
<span>加载系统事件中...</span>
<span>{t('loadingSystemEvents')}</span>
</div>
) : availableBuiltInEvents.length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
所有内置事件都已配置,没有可新增的事件。
{t('allBuiltinEventsConfigured')}
</div>
) : (
<Select
@@ -538,7 +541,9 @@ export function EventsTab() {
onValueChange={handleNewEventKeyChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择系统事件' />
<SelectValue
placeholder={t('selectSystemEventPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{availableBuiltInEvents.map((ev) => (
@@ -556,15 +561,17 @@ export function EventsTab() {
</div>
) : (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>系统异步任务</Label>
<Label className='text-xs font-semibold'>
{t('systemAsyncTask')}
</Label>
{taskTypesQuery.isLoading ? (
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
<span>加载异步任务中...</span>
<span>{t('loadingAsyncTasks')}</span>
</div>
) : (taskTypesQuery.data ?? []).length === 0 ? (
<div className='text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20'>
暂无可用的系统任务。
{t('noAvailableTasks')}
</div>
) : (
<Select
@@ -572,7 +579,9 @@ export function EventsTab() {
onValueChange={handleNewEventTaskTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='请选择异步任务' />
<SelectValue
placeholder={t('selectAsyncTaskPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{(taskTypesQuery.data ?? []).map((taskMeta) => (
@@ -593,11 +602,11 @@ export function EventsTab() {
{newEventType === 'builtin' && newEventKey && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'>
事件说明:
{t('eventDescription')}
</span>
<span>
{availableBuiltInEvents.find((e) => e.key === newEventKey)
?.description || '无描述信息'}
?.description || t('noDescription')}
</span>
</div>
)}
@@ -605,18 +614,20 @@ export function EventsTab() {
{newEventType === 'task' && newEventTaskType && (
<div className='text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1'>
<span className='font-semibold text-foreground'>
任务说明:
{t('taskDescription')}
</span>
<span>
{(taskTypesQuery.data ?? []).find(
(t) => t.asynq_task === newEventTaskType,
)?.description || '无描述信息'}
)?.description || t('noDescription')}
</span>
</div>
)}
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>推送渠道 (可多选)</Label>
<Label className='text-xs font-semibold'>
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
@@ -661,7 +672,7 @@ export function EventsTab() {
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
{t('noAvailableChannels')}
</div>
)}
</div>
@@ -671,7 +682,7 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
@@ -685,12 +696,12 @@ export function EventsTab() {
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式)
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{newEventType === 'task'
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
? t('taskTemplateVars')
: t('eventTemplateVars')}
</span>
</div>
<Textarea
@@ -704,9 +715,11 @@ export function EventsTab() {
<div className='flex items-center justify-between p-3 border rounded-lg bg-muted/10'>
<div className='space-y-0.5'>
<Label className='text-xs font-semibold'>启用状态</Label>
<Label className='text-xs font-semibold'>
{t('enableStatus')}
</Label>
<div className='text-[10px] text-muted-foreground'>
创建后是否立即开始接收此事件的通知
{t('enableStatusDesc')}
</div>
</div>
<Switch
@@ -723,7 +736,7 @@ export function EventsTab() {
onClick={() => setCreateEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -739,7 +752,7 @@ export function EventsTab() {
{createEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
启用保存
{t('enableSave')}
</Button>
</DialogFooter>
</DialogContent>
@@ -749,10 +762,8 @@ export function EventsTab() {
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
<DialogContent className='sm:max-w-[550px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>事件通知渠道配置</DialogTitle>
<DialogDescription>
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
</DialogDescription>
<DialogTitle>{t('editEventDialogTitle')}</DialogTitle>
<DialogDescription>{t('editEventDialogDesc')}</DialogDescription>
</DialogHeader>
{selectedEvent && (
@@ -760,7 +771,7 @@ export function EventsTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
事件名称
{t('eventName')}
</Label>
<Input
value={selectedEvent.name}
@@ -770,7 +781,7 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
事件键 (Key)
{t('eventKey')}
</Label>
<Input
value={selectedEvent.event_key}
@@ -783,7 +794,7 @@ export function EventsTab() {
{selectedEvent.task_type && (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold text-muted-foreground'>
关联异步任务
{t('relatedAsyncTask')}
</Label>
<Input
value={`${(taskTypesQuery.data ?? []).find((t) => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
@@ -795,7 +806,7 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送渠道 (可多选)
{t('pushChannelsMulti')}
</Label>
<Popover>
<PopoverTrigger asChild>
@@ -806,11 +817,11 @@ export function EventsTab() {
{eventChannels.length > 0
? eventChannels
.map((ch) => {
if (ch === 'email') return '邮件';
if (ch === 'email') return t('emailChannel');
return ch;
})
.join(', ')
: '选择已配置推送渠道'}
: t('selectConfiguredChannels')}
<ChevronDown className='ml-2 size-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
@@ -836,12 +847,12 @@ export function EventsTab() {
}
}}
/>
<span>{ch === 'email' ? '邮件推送' : ch}</span>
<span>{ch === 'email' ? t('emailPush') : ch}</span>
</label>
))}
{availableChannels.length === 0 && (
<div className='text-[11px] text-muted-foreground italic p-1'>
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
{t('noAvailableChannelsEdit')}
</div>
)}
</div>
@@ -851,11 +862,11 @@ export function EventsTab() {
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>
推送目标 (仅邮件等渠道需要)
{t('pushTargets')}
</Label>
<Input
type='text'
placeholder='多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com'
placeholder={t('pushTargetsPlaceholder')}
value={eventTargets}
onChange={(e) => setEventTargets(e.target.value)}
className='text-xs h-9'
@@ -865,12 +876,12 @@ export function EventsTab() {
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<Label className='text-xs font-semibold'>
内容渲染模板 (JSON 格式)
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
{selectedEvent.task_type
? '支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}'
: '支持变量:{{user.username}}, {{ip}}, {{time}}'}
? t('taskTemplateVars')
: t('eventTemplateVars')}
</span>
</div>
<Textarea
@@ -891,7 +902,7 @@ export function EventsTab() {
onClick={() => setEditEventOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -903,7 +914,7 @@ export function EventsTab() {
{updateEventMutation.isPending && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
保存修改
{t('saveChanges')}
</Button>
</DialogFooter>
</DialogContent>
@@ -35,6 +35,7 @@ import {
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import {
Dialog,
@@ -62,6 +63,7 @@ function getLevelBadgeVariant(
}
export function HistoriesTab() {
const t = useTranslations('admin.push.histories');
const [historyPage, setHistoryPage] = React.useState(1);
const [historySearch, setHistorySearch] = React.useState('');
const [historyStatus, setHistoryStatus] = React.useState('all');
@@ -99,7 +101,7 @@ export function HistoriesTab() {
<Search className='absolute left-2.5 top-2.5 size-4 text-muted-foreground' />
<Input
type='text'
placeholder='输入事件键过滤...'
placeholder={t('searchPlaceholder')}
className='pl-8 text-xs h-9'
value={historySearch}
onChange={(e) => {
@@ -117,17 +119,17 @@ export function HistoriesTab() {
}}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='发送状态' />
<SelectValue placeholder={t('sendStatus')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='all' className='text-xs'>
全部状态
{t('allStatus')}
</SelectItem>
<SelectItem value='success' className='text-xs'>
发送成功
{t('sendSuccess')}
</SelectItem>
<SelectItem value='failed' className='text-xs'>
发送失败
{t('sendFailed')}
</SelectItem>
</SelectContent>
</Select>
@@ -151,13 +153,27 @@ export function HistoriesTab() {
<Table>
<TableHeader>
<TableRow className='bg-muted/30'>
<TableHead className='text-xs font-semibold'>事件</TableHead>
<TableHead className='text-xs font-semibold'>渠道</TableHead>
<TableHead className='text-xs font-semibold'>目标</TableHead>
<TableHead className='text-xs font-semibold'>标题</TableHead>
<TableHead className='text-xs font-semibold'>等级</TableHead>
<TableHead className='text-xs font-semibold'>状态</TableHead>
<TableHead className='text-xs font-semibold'>时间</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colEvent')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colChannel')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTarget')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTitle')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colLevel')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colStatus')}
</TableHead>
<TableHead className='text-xs font-semibold'>
{t('colTime')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -166,7 +182,7 @@ export function HistoriesTab() {
<TableCell colSpan={7} className='h-32'>
<LoadingStateWithBorder
icon={History}
description='加载推送历史记录中...'
description={t('loadingHistories')}
className='border-0 shadow-none'
/>
</TableCell>
@@ -187,7 +203,7 @@ export function HistoriesTab() {
colSpan={7}
className='h-32 text-center text-xs text-muted-foreground'
>
无推送历史数据
{t('noHistoryData')}
</TableCell>
</TableRow>
) : (
@@ -201,7 +217,9 @@ export function HistoriesTab() {
{hist.event_key}
</TableCell>
<TableCell className='text-xs uppercase font-semibold text-muted-foreground'>
{hist.channel === 'email' ? '邮件' : hist.channel}
{hist.channel === 'email'
? t('emailChannel')
: hist.channel}
</TableCell>
<TableCell
className='text-xs font-mono text-muted-foreground max-w-[120px] truncate'
@@ -230,7 +248,7 @@ export function HistoriesTab() {
}
className='text-[10px] font-semibold'
>
{hist.status === 'success' ? '成功' : '失败'}
{hist.status === 'success' ? t('success') : t('failed')}
</Badge>
{hist.status !== 'success' && hist.error_msg && (
<div
@@ -253,7 +271,7 @@ export function HistoriesTab() {
{historiesQuery.data && historiesQuery.data.total > 0 && (
<div className='flex justify-between items-center text-xs text-muted-foreground'>
<span>共 {historiesQuery.data.total} 条历史记录</span>
<span>{t('totalRecords', { total: historiesQuery.data.total })}</span>
<div className='flex items-center gap-1.5'>
<Button
variant='outline'
@@ -284,10 +302,10 @@ export function HistoriesTab() {
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<History className='size-5 text-primary' />
推送通知详情
{t('pushNotificationDetail')}
</DialogTitle>
<DialogDescription>
查看该条通知发送的详细审计信息与内容
{t('pushNotificationDetailDesc')}
</DialogDescription>
</DialogHeader>
@@ -296,7 +314,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
事件标识
{t('eventIdentifier')}
</span>
<div className='font-mono bg-muted/40 p-2 rounded border'>
{selectedHistory.event_key}
@@ -304,11 +322,11 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送渠道
{t('sendChannel')}
</span>
<div className='bg-muted/40 p-2 rounded border uppercase font-medium'>
{selectedHistory.channel === 'email'
? '邮件推送'
? t('emailPush')
: selectedHistory.channel}
</div>
</div>
@@ -317,7 +335,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
推送目标
{t('pushTarget')}
</span>
<div
className='font-mono bg-muted/40 p-2 rounded border truncate'
@@ -328,7 +346,7 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送时间
{t('sendTime')}
</span>
<div className='bg-muted/40 p-2 rounded border'>
{new Date(selectedHistory.created_at).toLocaleString()}
@@ -339,7 +357,7 @@ export function HistoriesTab() {
<div className='grid grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知等级
{t('notificationLevel')}
</span>
<div>
<Badge
@@ -352,7 +370,7 @@ export function HistoriesTab() {
</div>
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
发送状态
{t('sendStatus')}
</span>
<div>
<Badge
@@ -363,7 +381,9 @@ export function HistoriesTab() {
}
className='text-[10px] font-semibold py-0.5 px-2'
>
{selectedHistory.status === 'success' ? '成功' : '失败'}
{selectedHistory.status === 'success'
? t('success')
: t('failed')}
</Badge>
</div>
</div>
@@ -373,7 +393,7 @@ export function HistoriesTab() {
selectedHistory.error_msg && (
<div className='space-y-1.5'>
<span className='font-semibold text-destructive block'>
失败原因
{t('failureReason')}
</span>
<div className='font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all'>
{selectedHistory.error_msg}
@@ -383,7 +403,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知标题
{t('notificationTitle')}
</span>
<div className='bg-muted/30 p-2.5 rounded border font-medium text-[13px]'>
{selectedHistory.title}
@@ -392,7 +412,7 @@ export function HistoriesTab() {
<div className='space-y-1.5'>
<span className='font-semibold text-muted-foreground block'>
通知内容
{t('notificationContent')}
</span>
<div className='bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs'>
{selectedHistory.content}
@@ -408,7 +428,7 @@ export function HistoriesTab() {
onClick={() => setDetailOpen(false)}
className='h-9 text-xs'
>
关闭
{t('close')}
</Button>
</DialogFooter>
</DialogContent>
@@ -8,6 +8,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Edit2, Loader2, Play, Plus, Settings, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -51,6 +52,7 @@ import type {
import { PushService } from '@/lib/services/push';
export function SettingsTab() {
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient();
// --- 获取所有自定义消息通道 ---
@@ -69,12 +71,12 @@ export function SettingsTab() {
const createChannelMutation = useMutation({
mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data),
onSuccess: () => {
toast.success('通道创建成功');
toast.success(t('channelCreateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false);
},
onError: (err: unknown) => {
toast.error('通道创建失败: ' + (err as Error).message);
toast.error(t('channelCreateFailed') + ': ' + (err as Error).message);
},
});
@@ -82,23 +84,23 @@ export function SettingsTab() {
mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) =>
PushService.updateChannel(id, data),
onSuccess: () => {
toast.success('通道更新成功');
toast.success(t('channelUpdateSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
setChannelDialogOpen(false);
},
onError: (err: unknown) => {
toast.error('通道更新失败: ' + (err as Error).message);
toast.error(t('channelUpdateFailed') + ': ' + (err as Error).message);
},
});
const deleteChannelMutation = useMutation({
mutationFn: (id: number) => PushService.deleteChannel(id),
onSuccess: () => {
toast.success('通道删除成功');
toast.success(t('channelDeleteSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] });
},
onError: (err: unknown) => {
toast.error('通道删除失败: ' + (err as Error).message);
toast.error(t('channelDeleteFailed') + ': ' + (err as Error).message);
},
});
@@ -181,16 +183,16 @@ export function SettingsTab() {
const handleSaveChannel = () => {
if (!channelName && !editingChannel) {
toast.error('通道名称不能为空');
toast.error(t('channelNameRequired'));
return;
}
if (!/^[a-zA-Z_0-9]+$/.test(channelName)) {
toast.error('通道名称只能使用英文字母、数字和下划线');
toast.error(t('channelNameFormat'));
return;
}
if (!activeDef) {
toast.error('无效的通道类型');
toast.error(t('invalidChannelType'));
return;
}
@@ -211,7 +213,7 @@ export function SettingsTab() {
// 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议)
if (channelType !== 'email') {
if (channelUrl && !channelUrl.startsWith('https://')) {
toast.error('地址必须以 https:// 开头以确保安全性');
toast.error(t('httpsRequired'));
return;
}
}
@@ -221,14 +223,14 @@ export function SettingsTab() {
try {
JSON.parse(channelOther);
} catch {
toast.error('请求体必须是合法的 JSON 格式');
toast.error(t('jsonFormatRequired'));
return;
}
} else if (channelType === 'lark' && channelOther) {
try {
JSON.parse(channelOther);
} catch {
toast.error('自定义卡片模版必须是合法的 JSON 格式');
toast.error(t('larkTemplateFormat'));
return;
}
}
@@ -267,15 +269,15 @@ export function SettingsTab() {
const handleSendChannelTest = async () => {
try {
setIsTestingChannel(true);
toast.info('正在发送测试推送...');
toast.info(t('sendingTestPush'));
await PushService.testChannel({
name: testChannelName,
target: testChannelTarget || undefined,
});
toast.success('测试推送发送成功,请前往对应平台确认。');
toast.success(t('testPushSuccess'));
setTestChannelOpen(false);
} catch (err: unknown) {
toast.error('连通性测试失败: ' + (err as Error).message);
toast.error(t('connectivityTestFailed') + ': ' + (err as Error).message);
} finally {
setIsTestingChannel(false);
}
@@ -285,9 +287,9 @@ export function SettingsTab() {
<div className='pt-4 space-y-4'>
<div className='flex items-center justify-between'>
<div>
<h2 className='text-sm font-semibold'>自定义推送通道</h2>
<h2 className='text-sm font-semibold'>{t('customPushChannels')}</h2>
<p className='text-[11px] text-muted-foreground mt-0.5'>
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
{t('customPushChannelsDesc')}
</p>
</div>
<Button
@@ -296,14 +298,14 @@ export function SettingsTab() {
className='text-xs'
>
<Plus className='size-3.5 mr-1' />
新建消息通道
{t('createChannel')}
</Button>
</div>
{channelsQuery.isLoading ? (
<LoadingStateWithBorder
icon={Settings}
description='加载消息通道中...'
description={t('loadingChannels')}
/>
) : channelsQuery.isError ? (
<div className='p-8 border border-dashed rounded-xl bg-card'>
@@ -319,9 +321,7 @@ export function SettingsTab() {
className='size-8 mb-2 opacity-30 animate-spin'
style={{ animationDuration: '3s' }}
/>
<span className='text-xs font-medium'>
暂无自定义通道配置,请点击右上角新建
</span>
<span className='text-xs font-medium'>{t('noCustomChannels')}</span>
</div>
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
@@ -329,19 +329,19 @@ export function SettingsTab() {
<TableHeader className='sticky top-0 z-20 bg-background'>
<TableRow className='border-b border-dashed hover:bg-transparent'>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
名称
{t('colName')}
</TableHead>
<TableHead className='w-[100px] whitespace-nowrap py-2 h-8'>
类型
{t('colType')}
</TableHead>
<TableHead className='whitespace-nowrap py-2 h-8'>
备注
{t('colRemark')}
</TableHead>
<TableHead className='w-[80px] text-center whitespace-nowrap py-2 h-8'>
状态
{t('colStatus')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[180px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -366,7 +366,9 @@ export function SettingsTab() {
</Badge>
</TableCell>
<TableCell className='text-xs text-muted-foreground max-w-[200px] truncate py-1'>
{ch.description || <span className='italic'>无备注</span>}
{ch.description || (
<span className='italic'>{t('noRemark')}</span>
)}
</TableCell>
<TableCell
className='text-center py-1'
@@ -402,7 +404,7 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-primary hover:text-primary hover:bg-primary/10'
>
<Play className='size-2.5 mr-1' />
测试
{t('test')}
</Button>
<Button
variant='ghost'
@@ -411,21 +413,25 @@ export function SettingsTab() {
className='h-6 px-2 text-[10px] text-muted-foreground hover:text-foreground'
>
<Edit2 className='size-2.5 mr-1' />
编辑
{t('edit')}
</Button>
<Button
variant='ghost'
size='sm'
disabled={deleteChannelMutation.isPending}
onClick={() => {
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
if (
confirm(
t('deleteChannelConfirm', { name: ch.name }),
)
) {
deleteChannelMutation.mutate(ch.id);
}
}}
className='h-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10'
>
<Trash2 className='size-2.5 mr-1' />
删除
{t('delete')}
</Button>
</div>
</TableCell>
@@ -441,35 +447,38 @@ export function SettingsTab() {
<DialogContent className='sm:max-w-[600px] max-h-[85vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>
{editingChannel ? '编辑消息通道' : '新建消息通道'}
{editingChannel
? t('editChannelDialogTitle')
: t('createChannelDialogTitle')}
</DialogTitle>
<DialogDescription>
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook
或推送服务投递数据
</DialogDescription>
<DialogDescription>{t('channelDialogDesc')}</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>名称</Label>
<Label className='text-xs font-semibold'>
{t('channelName')}
</Label>
<Input
type='text'
placeholder='请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一'
placeholder={t('channelNamePlaceholder')}
value={channelName}
onChange={(e) => setChannelName(e.target.value)}
disabled={!!editingChannel}
className='text-xs h-9 font-mono'
/>
<p className='text-[10px] text-muted-foreground'>
通道唯一标识,创建后不可修改
{t('channelNameHint')}
</p>
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>备注</Label>
<Label className='text-xs font-semibold'>
{t('channelRemark')}
</Label>
<Input
type='text'
placeholder='请输入备注信息'
placeholder={t('channelRemarkPlaceholder')}
value={channelDescription}
onChange={(e) => setChannelDescription(e.target.value)}
className='text-xs h-9'
@@ -477,13 +486,15 @@ export function SettingsTab() {
</div>
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>通道类型</Label>
<Label className='text-xs font-semibold'>
{t('channelType')}
</Label>
<Select
value={channelType}
onValueChange={handleChannelTypeChange}
>
<SelectTrigger className='text-xs h-9'>
<SelectValue placeholder='选择通道类型' />
<SelectValue placeholder={t('selectChannelType')} />
</SelectTrigger>
<SelectContent>
{(definitionsQuery.data ?? []).map((d) => (
@@ -562,7 +573,7 @@ export function SettingsTab() {
{channelType === 'custom' && (
<div className='p-3.5 border rounded-lg bg-muted/20 space-y-2.5'>
<Label className='text-[11px] font-semibold'>
快捷加载常用模版实例:
{t('quickLoadTemplates')}
</Label>
<div className='flex flex-wrap gap-1.5'>
<Button
@@ -586,7 +597,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载飞书 Webhook 模版');
toast.success(t('loadedFeishuTemplate'));
}}
>
飞书 Webhook
@@ -613,7 +624,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载钉钉机器人模版');
toast.success(t('loadedDingTalkTemplate'));
}}
>
钉钉群机器人
@@ -640,7 +651,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载企业微信机器人模版');
toast.success(t('loadedWeChatTemplate'));
}}
>
企业微信群机器人
@@ -664,7 +675,7 @@ export function SettingsTab() {
2,
),
);
toast.success('已加载 Bark App 模版');
toast.success(t('loadedBarkTemplate'));
}}
>
Bark App
@@ -683,7 +694,7 @@ export function SettingsTab() {
onClick={() => setChannelDialogOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -699,7 +710,7 @@ export function SettingsTab() {
updateChannelMutation.isPending) && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
确定
{t('confirm')}
</Button>
</DialogFooter>
</DialogContent>
@@ -709,15 +720,15 @@ export function SettingsTab() {
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
<DialogContent className='sm:max-w-[450px]'>
<DialogHeader>
<DialogTitle>发送测试通知</DialogTitle>
<DialogTitle>{t('sendTestNotification')}</DialogTitle>
<DialogDescription>
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
{t('sendTestNotificationDesc')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-3'>
<div className='space-y-1.5'>
<Label className='text-xs'>推送通道名称</Label>
<Label className='text-xs'>{t('pushChannelName')}</Label>
<Input
type='text'
value={testChannelName}
@@ -726,10 +737,10 @@ export function SettingsTab() {
/>
</div>
<div className='space-y-1.5'>
<Label className='text-xs'>测试推送目标 (对应模板变量 $to)</Label>
<Label className='text-xs'>{t('testPushTarget')}</Label>
<Input
type='text'
placeholder='请输入测试推送接收人/目标标识,如 Bark Token、邮箱等'
placeholder={t('testPushTargetPlaceholder')}
value={testChannelTarget}
onChange={(e) => setTestChannelTarget(e.target.value)}
className='text-xs h-9'
@@ -744,7 +755,7 @@ export function SettingsTab() {
onClick={() => setTestChannelOpen(false)}
className='h-9 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='default'
@@ -756,7 +767,7 @@ export function SettingsTab() {
{isTestingChannel && (
<Loader2 className='size-3 animate-spin mr-1' />
)}
发送测试
{t('sendTest')}
</Button>
</DialogFooter>
</DialogContent>
+6 -4
View File
@@ -6,6 +6,7 @@
import * as React from 'react';
import { motion } from 'motion/react';
import { Bell, History, Layers, Settings } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { EventsTab } from './components/events-tab';
@@ -13,6 +14,7 @@ import { HistoriesTab } from './components/histories-tab';
import { SettingsTab } from './components/settings-tab';
export default function PushAdminPage() {
const t = useTranslations('admin.push');
const [activeTab, setActiveTab] = React.useState('events');
return (
@@ -26,7 +28,7 @@ export default function PushAdminPage() {
<Bell className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
通知推送管理
{t('pageTitle')}
</h1>
</div>
</div>
@@ -38,21 +40,21 @@ export default function PushAdminPage() {
className='px-0 pb-2 text-xs font-semibold'
>
<Layers className='size-3.5 mr-1' />
事件管理
{t('eventsTab')}
</TabsTrigger>
<TabsTrigger
value='histories'
className='px-0 pb-2 text-xs font-semibold'
>
<History className='size-3.5 mr-1' />
通知历史
{t('historiesTab')}
</TabsTrigger>
<TabsTrigger
value='settings'
className='px-0 pb-2 text-xs font-semibold'
>
<Settings className='size-3.5 mr-1' />
通道管理与设置
{t('settingsTab')}
</TabsTrigger>
</TabsList>
@@ -2,6 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
Activity,
@@ -45,6 +46,7 @@ const formatNumber = (num: number | string) => {
* 运行时系统状态展示与管理组件
*/
export function SystemStatusManager() {
const t = useTranslations('admin.settings.status');
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [wavelet, setWavelet] = useState(false);
@@ -58,38 +60,42 @@ export function SystemStatusManager() {
);
// 获取状态数据
const fetchStatus = useCallback(async (isSilent = false) => {
if (!isSilent) setWavelet(true);
try {
const data = await services.adminStatus.getSystemStatus();
const fetchStatus = useCallback(
async (isSilent = false) => {
if (!isSilent) setWavelet(true);
try {
const data = await services.adminStatus.getSystemStatus();
// 检测变化的字段用于微动画效果
if (prevStatusRef.current) {
const changes: Record<string, boolean> = {};
Object.keys(data).forEach((key) => {
const k = key as keyof SystemStatus;
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
changes[k] = true;
}
// 检测变化的字段用于微动画效果
if (prevStatusRef.current) {
const changes: Record<string, boolean> = {};
Object.keys(data).forEach((key) => {
const k = key as keyof SystemStatus;
if (prevStatusRef.current && prevStatusRef.current[k] !== data[k]) {
changes[k] = true;
}
});
setChangedFields(changes);
// 1秒后清除变化状态
setTimeout(() => {
setChangedFields({});
}, 1000);
}
setStatus(data);
prevStatusRef.current = data;
} catch (err) {
toast.error(t('fetchFailed'), {
description:
err instanceof Error ? err.message : t('unknownRequestError'),
});
setChangedFields(changes);
// 1秒后清除变化状态
setTimeout(() => {
setChangedFields({});
}, 1000);
} finally {
setLoading(false);
setWavelet(false);
}
setStatus(data);
prevStatusRef.current = data;
} catch (err) {
toast.error('获取系统状态失败', {
description: err instanceof Error ? err.message : '请求时发生未知错误',
});
} finally {
setLoading(false);
setWavelet(false);
}
}, []);
},
[t],
);
// 轮询逻辑
useEffect(() => {
@@ -196,7 +202,9 @@ export function SystemStatusManager() {
<div className='flex items-center gap-2'>
<Activity className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>系统状态</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
@@ -211,7 +219,7 @@ export function SystemStatusManager() {
htmlFor='auto-refresh'
className='text-xs font-medium cursor-pointer select-none'
>
自动刷新
{t('autoRefresh')}
</label>
</div>
@@ -237,7 +245,7 @@ export function SystemStatusManager() {
disabled={wavelet}
>
<RefreshCw className={`size-3 ${wavelet ? 'animate-spin' : ''}`} />
刷新
{t('refresh')}
</Button>
</div>
</div>
@@ -251,10 +259,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
服务概览
{t('cards.serviceOverview')}
</CardTitle>
<CardDescription className='text-[10px]'>
服务的基础生命指标
{t('cards.serviceOverviewDesc')}
</CardDescription>
</div>
<Badge
@@ -266,17 +274,17 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='服务运行时间'
label={t('metrics.uptime')}
value={status.uptime}
field='uptime'
/>
<RenderMetricRow
label='当前 Goroutines 数量'
label={t('metrics.goroutines')}
value={status.num_goroutine}
field='num_goroutine'
/>
<RenderMetricRow
label='Heap 对象数量'
label={t('metrics.heapObjects')}
value={status.heap_objects}
field='heap_objects'
/>
@@ -288,10 +296,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
内存统计
{t('cards.memoryStats')}
</CardTitle>
<CardDescription className='text-[10px]'>
主内存消耗概览
{t('cards.memoryStatsDesc')}
</CardDescription>
</div>
<Badge
@@ -304,22 +312,22 @@ export function SystemStatusManager() {
<CardContent className='space-y-3'>
<div className='space-y-1'>
<RenderMetricRow
label='当前内存使用量'
label={t('metrics.alloc')}
value={status.alloc}
field='alloc'
/>
<RenderMetricRow
label='所有已分配的内存'
label={t('metrics.totalAlloc')}
value={status.total_alloc}
field='total_alloc'
/>
<RenderMetricRow
label='内存占用量'
label={t('metrics.sys')}
value={status.sys}
field='sys'
/>
<RenderMetricRow
label='下次 GC 内存回收量'
label={t('metrics.nextGc')}
value={status.next_gc}
field='next_gc'
/>
@@ -327,7 +335,7 @@ export function SystemStatusManager() {
{/* 物理内存水位比例 */}
<div className='space-y-1 px-1 pt-1'>
<div className='flex justify-between text-[10px] text-muted-foreground'>
<span>当前使用率 (Alloc / Sys)</span>
<span>{t('metrics.memoryUsagePercent')}</span>
<span className='font-mono'>
{getMemoryUsagePercent()}%
</span>
@@ -342,10 +350,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
堆/栈详情
{t('cards.heapStack')}
</CardTitle>
<CardDescription className='text-[10px]'>
运行时堆栈内存空间细节
{t('cards.heapStackDesc')}
</CardDescription>
</div>
<Badge
@@ -357,37 +365,37 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='当前 Heap 内存使用量'
label={t('metrics.heapAlloc')}
value={status.heap_alloc}
field='heap_alloc'
/>
<RenderMetricRow
label='Heap 内存占用量'
label={t('metrics.heapSys')}
value={status.heap_sys}
field='heap_sys'
/>
<RenderMetricRow
label='Heap 内存空闲量'
label={t('metrics.heapIdle')}
value={status.heap_idle}
field='heap_idle'
/>
<RenderMetricRow
label='正在使用的 Heap 内存'
label={t('metrics.heapInuse')}
value={status.heap_inuse}
field='heap_inuse'
/>
<RenderMetricRow
label='已释放的 Heap 内存'
label={t('metrics.heapReleased')}
value={status.heap_released}
field='heap_released'
/>
<RenderMetricRow
label='启动 Stack 使用量'
label={t('metrics.stackInuse')}
value={status.stack_inuse}
field='stack_inuse'
/>
<RenderMetricRow
label='已分配的 Stack 内存'
label={t('metrics.stackSys')}
value={status.stack_sys}
field='stack_sys'
/>
@@ -399,10 +407,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
底层及结构体内存
{t('cards.internalStructs')}
</CardTitle>
<CardDescription className='text-[10px]'>
运行时底层管理结构体开销
{t('cards.internalStructsDesc')}
</CardDescription>
</div>
<Badge
@@ -414,37 +422,37 @@ export function SystemStatusManager() {
</CardHeader>
<CardContent className='space-y-1'>
<RenderMetricRow
label='MSpan 结构内存使用量'
label={t('metrics.mspanInuse')}
value={status.mspan_inuse}
field='mspan_inuse'
/>
<RenderMetricRow
label='已分配的 MSpan 结构内存'
label={t('metrics.mspanSys')}
value={status.mspan_sys}
field='mspan_sys'
/>
<RenderMetricRow
label='MCache 结构内存使用量'
label={t('metrics.mcacheInuse')}
value={status.mcache_inuse}
field='mcache_inuse'
/>
<RenderMetricRow
label='已分配的 MCache 结构内存'
label={t('metrics.mcacheSys')}
value={status.mcache_sys}
field='mcache_sys'
/>
<RenderMetricRow
label='已分配的剖析哈希表内存'
label={t('metrics.buckHashSys')}
value={status.buck_hash_sys}
field='buck_hash_sys'
/>
<RenderMetricRow
label='已分配的 GC 元数据内存'
label={t('metrics.gcSys')}
value={status.gc_sys}
field='gc_sys'
/>
<RenderMetricRow
label='其它已分配的系统内存'
label={t('metrics.otherSys')}
value={status.other_sys}
field='other_sys'
/>
@@ -456,10 +464,10 @@ export function SystemStatusManager() {
<CardHeader className='flex flex-row items-center justify-between pb-2 space-y-0'>
<div className='space-y-0.5'>
<CardTitle className='text-sm font-semibold'>
垃圾回收与分配计数
{t('cards.gcAndAlloc')}
</CardTitle>
<CardDescription className='text-[10px]'>
GC 历史数据与分配频次
{t('cards.gcAndAllocDesc')}
</CardDescription>
</div>
<Badge
@@ -473,39 +481,39 @@ export function SystemStatusManager() {
<div className='grid grid-cols-1 sm:grid-cols-2 gap-x-6'>
<div>
<RenderMetricRow
label='GC 执行次数'
label={t('metrics.numGc')}
value={status.num_gc}
field='num_gc'
/>
<RenderMetricRow
label='距离上次 GC 时间'
label={t('metrics.lastGcTime')}
value={status.last_gc_time}
field='last_gc_time'
/>
<RenderMetricRow
label='GC 暂停时间总量'
label={t('metrics.pauseTotalNs')}
value={status.pause_total_ns}
field='pause_total_ns'
/>
<RenderMetricRow
label='上次 GC 暂停时间'
label={t('metrics.lastPause')}
value={status.last_pause}
field='last_pause'
/>
</div>
<div>
<RenderMetricRow
label='内存分配次数'
label={t('metrics.mallocs')}
value={status.mallocs}
field='mallocs'
/>
<RenderMetricRow
label='内存释放次数'
label={t('metrics.frees')}
value={status.frees}
field='frees'
/>
<RenderMetricRow
label='指针查找次数'
label={t('metrics.lookups')}
value={status.lookups}
field='lookups'
/>
@@ -5,6 +5,7 @@
import dynamic from 'next/dynamic';
import { useEffect, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useQuery } from '@tanstack/react-query';
import { Loader2, Settings } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
@@ -71,6 +72,7 @@ function systemConfigMap(configs: SystemConfig[]) {
}
export function AdminSettingsPageClient() {
const t = useTranslations('admin.settings');
const { user, loading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
@@ -127,7 +129,9 @@ export function AdminSettingsPageClient() {
<div className='flex items-center gap-2'>
<Settings className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>系统设置</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
<Tabs
@@ -140,34 +144,34 @@ export function AdminSettingsPageClient() {
value='security'
className='px-0 pb-2 text-xs font-semibold'
>
安全设置
{t('tabs.security')}
</TabsTrigger>
<TabsTrigger
value='operation'
className='px-0 pb-2 text-xs font-semibold'
>
业务设置
{t('tabs.operation')}
</TabsTrigger>
<TabsTrigger
value='system'
className='px-0 pb-2 text-xs font-semibold'
>
系统设置
{t('tabs.system')}
</TabsTrigger>
<TabsTrigger
value='other'
className='px-0 pb-2 text-xs font-semibold'
>
其他设置
{t('tabs.other')}
</TabsTrigger>
<TabsTrigger
value='status'
className='px-0 pb-2 text-xs font-semibold'
>
系统状态
{t('tabs.status')}
</TabsTrigger>
<TabsTrigger value='info' className='px-0 pb-2 text-xs font-semibold'>
系统信息
{t('tabs.info')}
</TabsTrigger>
</TabsList>
+26 -19
View File
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
@@ -51,13 +52,14 @@ function SystemConfigDetailPanel({
onSave: () => void;
saving: boolean;
}) {
const t = useTranslations('admin.system');
return (
<ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}>
<div className='grid grid-cols-1 gap-0'>
<div className='border border-dashed rounded-lg'>
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置键
{t('configKey')}
</label>
<p className='text-xs text-muted-foreground font-mono'>
{config?.key}
@@ -66,7 +68,7 @@ function SystemConfigDetailPanel({
<div className='pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置值
{t('configValue')}
</label>
<div className='flex gap-1 w-[90%] justify-end items-center pr-3'>
{config?.key.endsWith('_enabled') ? (
@@ -107,7 +109,9 @@ function SystemConfigDetailPanel({
: config?.value || ''
}
placeholder={
editData.value === undefined && !config?.value ? '必需' : ''
editData.value === undefined && !config?.value
? t('required')
: ''
}
onChange={(e) => {
const value = e.target.value;
@@ -128,7 +132,7 @@ function SystemConfigDetailPanel({
<div className='pl-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置描述
{t('configDescription')}
</label>
<div className='flex gap-1 w-[90%]'>
<Input
@@ -138,7 +142,7 @@ function SystemConfigDetailPanel({
? editData.description
: config?.description || ''
}
placeholder='可选描述'
placeholder={t('optionalDescription')}
onChange={(e) => {
const value = e.target.value;
onEditDataChange('description', value);
@@ -150,7 +154,7 @@ function SystemConfigDetailPanel({
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
公共可见
{t('publicVisibility')}
</label>
<Switch
checked={(editData.visibility ?? config?.visibility ?? 0) === 1}
@@ -162,7 +166,7 @@ function SystemConfigDetailPanel({
<div className='px-3 py-2 flex items-center justify-between border-b border-dashed last:border-b-0'>
<label className='text-xs font-medium text-muted-foreground'>
配置类型
{t('configType')}
</label>
<span
className={`text-[11px] px-1.5 py-0.5 rounded font-medium ${
@@ -171,13 +175,15 @@ function SystemConfigDetailPanel({
: 'bg-indigo-500/10 text-indigo-600 dark:bg-indigo-500/20 dark:text-indigo-400'
}`}
>
{config?.type === 'system' ? '系统配置' : '业务配置'}
{config?.type === 'system'
? t('systemConfig')
: t('businessConfig')}
</span>
</div>
<div className='px-3 py-2 flex items-center justify-between'>
<label className='text-xs font-medium text-muted-foreground'>
创建时间
{t('createdAt')}
</label>
<p className='text-xs text-muted-foreground'>
{config ? formatDateTime(config.created_at) : ''}
@@ -199,6 +205,7 @@ function SystemConfigDetailPanel({
* @returns {React.ReactNode} 系统配置管理组件
*/
export function SystemConfigs() {
const t = useTranslations('admin.system');
const {
systemConfigs: configs,
systemConfigsLoading: loading,
@@ -236,7 +243,7 @@ export function SystemConfigs() {
return (
<ManagePage<SystemConfig>
title='系统配置'
title={t('pageTitle')}
icon={ShieldCheck}
data={configs}
loading={loading}
@@ -245,8 +252,8 @@ export function SystemConfigs() {
getInitialEditData={getInitialEditData}
onSave={handleSave}
getId={(config) => config.key}
emptyDescription='未发现系统配置'
loadingDescription='配置加载中'
emptyDescription={t('emptyDescription')}
loadingDescription={t('loadingDescription')}
headerExtra={
<Tabs
value={activeTab}
@@ -255,24 +262,24 @@ export function SystemConfigs() {
>
<TabsList className='grid w-full grid-cols-2 h-8'>
<TabsTrigger value='business' className='text-[11px] h-7'>
业务配置
{t('businessConfig')}
</TabsTrigger>
<TabsTrigger value='system' className='text-[11px] h-7'>
系统配置
{t('systemConfig')}
</TabsTrigger>
</TabsList>
</Tabs>
}
columns={[
{
header: '配置键',
header: t('configKey'),
cell: (item) => (
<span className='font-mono font-medium'>{item.key}</span>
),
width: '200px',
},
{
header: '配置值',
header: t('configValue'),
cell: (item) => (
<span
className='truncate max-w-[120px] inline-block'
@@ -284,14 +291,14 @@ export function SystemConfigs() {
width: '120px',
},
{
header: '公共可见',
header: t('publicVisibility'),
cell: (item) => (
<span>{item.visibility === 1 ? '可见' : '不可见'}</span>
<span>{item.visibility === 1 ? t('visible') : t('invisible')}</span>
),
width: '80px',
},
{
header: '描述',
header: t('description'),
cell: (item) => (
<span
className='truncate max-w-[200px] inline-block text-muted-foreground'
@@ -1,6 +1,7 @@
'use client';
import { useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format } from 'date-fns';
@@ -49,18 +50,18 @@ import {
TableRow,
} from '@/components/ui/table';
const STATUS_LABELS: Record<TaskExecutionStatus, string> = {
pending: '等待中',
running: '执行中',
succeeded: '成功',
failed: '失败',
const STATUS_LABELS_KEYS: Record<TaskExecutionStatus, string> = {
pending: 'statusPending',
running: 'statusRunning',
succeeded: 'statusSucceeded',
failed: 'statusFailed',
};
const TRIGGER_LABELS: Record<string, string> = {
system: '系统',
manual: '手动',
retry: '重试',
schedule: '定时',
const TRIGGER_LABELS_KEYS: Record<string, string> = {
system: 'triggerSystem',
manual: 'triggerManual',
retry: 'triggerRetry',
schedule: 'triggerSchedule',
};
const PAGE_SIZE = 10;
@@ -85,6 +86,7 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function TaskExecutionsManager() {
const t = useTranslations('admin.tasks');
const queryClient = useQueryClient();
const [executionsPage, setExecutionsPage] = useState(1);
const [executionStatus, setExecutionStatus] = useState<
@@ -129,16 +131,16 @@ export function TaskExecutionsManager() {
const retryMutation = useMutation({
mutationFn: (id: string) => services.adminTask.retryTaskExecution(id),
onSuccess: (taskID) => {
toast.success('任务已重新下发', {
description: `新任务 ID:${taskID}`,
toast.success(t('retrySuccess'), {
description: t('retrySuccessDesc', { taskId: taskID }),
});
void queryClient.invalidateQueries({
queryKey: ['admin', 'task-executions'],
});
},
onError: (err: Error) => {
toast.error('任务重试失败', {
description: err.message || '未知错误',
toast.error(t('retryFailed'), {
description: err.message || t('unknownError'),
});
},
});
@@ -190,11 +192,11 @@ export function TaskExecutionsManager() {
<SelectValue placeholder='状态' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部状态</SelectItem>
<SelectItem value='pending'>等待中</SelectItem>
<SelectItem value='running'>执行中</SelectItem>
<SelectItem value='succeeded'>成功</SelectItem>
<SelectItem value='failed'>失败</SelectItem>
<SelectItem value='all'>{t('allStatuses')}</SelectItem>
<SelectItem value='pending'>{t('statusPending')}</SelectItem>
<SelectItem value='running'>{t('statusRunning')}</SelectItem>
<SelectItem value='succeeded'>{t('statusSucceeded')}</SelectItem>
<SelectItem value='failed'>{t('statusFailed')}</SelectItem>
</SelectContent>
</Select>
<Select
@@ -205,7 +207,7 @@ export function TaskExecutionsManager() {
<SelectValue placeholder='任务类型' />
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>全部任务</SelectItem>
<SelectItem value='all'>{t('allTasks')}</SelectItem>
{taskTypes.map((task) => (
<SelectItem key={task.type} value={task.type}>
{task.name || task.type}
@@ -224,7 +226,7 @@ export function TaskExecutionsManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新
{t('refresh')}
</Button>
</div>
</div>
@@ -240,22 +242,24 @@ export function TaskExecutionsManager() {
) : executionsLoading && executions.length === 0 ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务执行记录中...'
description={t('loadingExecutions')}
/>
) : executions.length === 0 ? (
<EmptyStateWithBorder icon={Activity} description='暂无任务执行记录' />
<EmptyStateWithBorder icon={Activity} description={t('noExecutions')} />
) : (
<div className='rounded-lg border bg-card'>
<Table className='min-w-[900px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务</TableHead>
<TableHead className='w-[100px]'>状态</TableHead>
<TableHead className='w-[110px]'>触发</TableHead>
<TableHead className='w-[120px]'>重试</TableHead>
<TableHead className='w-[120px]'>耗时</TableHead>
<TableHead className='min-w-[220px]'>结果/错误</TableHead>
<TableHead className='w-[170px]'>创建时间</TableHead>
<TableHead className='w-[180px]'>{t('colTask')}</TableHead>
<TableHead className='w-[100px]'>{t('colStatus')}</TableHead>
<TableHead className='w-[110px]'>{t('colTrigger')}</TableHead>
<TableHead className='w-[120px]'>{t('colRetry')}</TableHead>
<TableHead className='w-[120px]'>{t('colDuration')}</TableHead>
<TableHead className='min-w-[220px]'>
{t('colResult')}
</TableHead>
<TableHead className='w-[170px]'>{t('colCreatedAt')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -277,12 +281,13 @@ export function TaskExecutionsManager() {
</TableCell>
<TableCell>
<Badge variant={statusVariant(execution.status)}>
{STATUS_LABELS[execution.status] || execution.status}
{t(STATUS_LABELS_KEYS[execution.status]) ||
execution.status}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline'>
{TRIGGER_LABELS[execution.triggered_by] ||
{t(TRIGGER_LABELS_KEYS[execution.triggered_by]) ||
execution.triggered_by}
</Badge>
</TableCell>
@@ -307,7 +312,11 @@ export function TaskExecutionsManager() {
<div className='flex items-center justify-between'>
<div className='text-xs text-muted-foreground'>
共 {executionsTotal} 条,当前第 {executionsPage}/{totalPages} 页
{t('paginationInfo', {
total: executionsTotal,
current: executionsPage,
totalPages,
})}
</div>
<div className='flex items-center gap-2'>
<Button
@@ -317,7 +326,7 @@ export function TaskExecutionsManager() {
disabled={executionsPage <= 1 || executionsLoading}
>
<ChevronLeft className='size-4' />
上一页
{t('prevPage')}
</Button>
<Button
variant='outline'
@@ -327,7 +336,7 @@ export function TaskExecutionsManager() {
}
disabled={executionsPage >= totalPages || executionsLoading}
>
下一页
{t('nextPage')}
<ChevronRight className='size-4' />
</Button>
</div>
@@ -336,7 +345,7 @@ export function TaskExecutionsManager() {
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
<SheetContent className='w-full p-0 sm:max-w-[640px]'>
<SheetHeader className='border-b'>
<SheetTitle>任务执行详情</SheetTitle>
<SheetTitle>{t('executionDetailTitle')}</SheetTitle>
<SheetDescription>
{selectedExecution?.task_name ||
selectedExecution?.task_type ||
@@ -348,32 +357,34 @@ export function TaskExecutionsManager() {
{detailLoading && !selectedExecution ? (
<LoadingStateWithBorder
icon={Activity}
description='加载任务详情中...'
description={t('loadingDetail')}
/>
) : selectedExecution ? (
<div className='space-y-5 py-4'>
<div className='grid grid-cols-2 gap-3'>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>状态</div>
<div className='text-xs text-muted-foreground'>
{t('detailStatus')}
</div>
<div className='mt-2'>
<Badge variant={statusVariant(selectedExecution.status)}>
{STATUS_LABELS[selectedExecution.status] ||
{t(STATUS_LABELS_KEYS[selectedExecution.status]) ||
selectedExecution.status}
</Badge>
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
触发来源
{t('detailTrigger')}
</div>
<div className='mt-2 text-sm font-medium'>
{TRIGGER_LABELS[selectedExecution.triggered_by] ||
{t(TRIGGER_LABELS_KEYS[selectedExecution.triggered_by]) ||
selectedExecution.triggered_by}
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>
重试次数
{t('detailRetryCount')}
</div>
<div className='mt-2 font-mono text-sm'>
{selectedExecution.retry_count}/
@@ -381,7 +392,9 @@ export function TaskExecutionsManager() {
</div>
</div>
<div className='rounded-lg border p-3'>
<div className='text-xs text-muted-foreground'>耗时</div>
<div className='text-xs text-muted-foreground'>
{t('detailDuration')}
</div>
<div className='mt-2 font-mono text-sm'>
{formatDuration(selectedExecution.duration)}
</div>
@@ -389,7 +402,7 @@ export function TaskExecutionsManager() {
</div>
<div className='grid gap-2'>
<Label>任务标识</Label>
<Label>{t('detailTaskId')}</Label>
<div className='rounded-md border bg-muted/40 px-3 py-2 font-mono text-xs break-all'>
{selectedExecution.task_id}
</div>
@@ -397,25 +410,25 @@ export function TaskExecutionsManager() {
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
<div className='grid gap-2'>
<Label>创建时间</Label>
<Label>{t('detailCreatedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.created_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>开始时间</Label>
<Label>{t('detailStartedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.started_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>结束时间</Label>
<Label>{t('detailFinishedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.finished_at)}
</div>
</div>
<div className='grid gap-2'>
<Label>更新时间</Label>
<Label>{t('detailUpdatedAt')}</Label>
<div className='font-mono text-xs text-muted-foreground'>
{formatDateTime(selectedExecution.updated_at)}
</div>
@@ -423,7 +436,7 @@ export function TaskExecutionsManager() {
</div>
<div className='grid gap-2'>
<Label>执行结果</Label>
<Label>{t('detailResult')}</Label>
<div className='min-h-10 rounded-md border bg-muted/30 px-3 py-2 text-sm whitespace-pre-wrap break-all'>
{selectedExecution.result || '-'}
</div>
@@ -431,7 +444,7 @@ export function TaskExecutionsManager() {
{selectedExecution.error_message && (
<div className='grid gap-2'>
<Label>错误信息</Label>
<Label>{t('detailError')}</Label>
<div className='rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive whitespace-pre-wrap break-all'>
{selectedExecution.error_message}
</div>
@@ -439,23 +452,23 @@ export function TaskExecutionsManager() {
)}
<div className='grid gap-2'>
<Label>Payload</Label>
<Label>{t('detailPayload')}</Label>
<pre className='max-h-40 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selectedExecution.payload || '{}'}
</pre>
</div>
<div className='grid gap-2'>
<Label>执行日志</Label>
<Label>{t('detailLog')}</Label>
<pre className='min-h-48 max-h-[420px] overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed whitespace-pre-wrap'>
{selectedExecution.log || '暂无日志'}
{selectedExecution.log || t('noLog')}
</pre>
</div>
</div>
) : (
<EmptyStateWithBorder
icon={Activity}
description='未选择任务记录'
description={t('noExecutionSelected')}
/>
)}
</div>
@@ -471,7 +484,7 @@ export function TaskExecutionsManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新详情
{t('refreshDetail')}
</Button>
{selectedExecution &&
selectedExecution.status === 'failed' &&
@@ -485,7 +498,7 @@ export function TaskExecutionsManager() {
) : (
<RotateCcw className='size-4' />
)}
重试任务
{t('retryTask')}
</Button>
)}
</SheetFooter>
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -166,6 +167,7 @@ function DatePickerWithTime({
}
export function TaskManager() {
const t = useTranslations('admin.tasks');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [taskTypes, setTaskTypes] = useState<TaskMeta[]>([]);
@@ -186,11 +188,13 @@ export function TaskManager() {
const data = await services.adminTask.getTaskTypes();
setTaskTypes(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('加载任务类型失败'));
setError(
err instanceof Error ? err : new Error(t('loadTaskTypesFailed')),
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchTaskTypes();
@@ -249,8 +253,11 @@ export function TaskManager() {
const taskID = await services.adminTask.dispatchTask(params);
toast.success('任务下发成功', {
description: `已成功将任务 ${targetTask?.name || selectedTaskType} 加入队列:${taskID}`,
toast.success(t('dispatchSuccess'), {
description: t('dispatchSuccessDesc', {
name: targetTask?.name || selectedTaskType,
taskId: taskID,
}),
});
setDialogOpen(false);
@@ -259,8 +266,8 @@ export function TaskManager() {
setUserId('');
setParamValues({});
} catch (err) {
toast.error('任务下发失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('dispatchFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setDispatching(false);
@@ -292,10 +299,10 @@ export function TaskManager() {
) : loading && taskTypes.length === 0 ? (
<LoadingStateWithBorder
icon={Layers}
description='加载任务类型中...'
description={t('loadingTaskTypes')}
/>
) : taskTypes.length === 0 ? (
<EmptyStateWithBorder icon={Layers} description='暂无可用任务类型' />
<EmptyStateWithBorder icon={Layers} description={t('noTaskTypes')} />
) : (
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4'>
{taskTypes.map((task, index) => {
@@ -333,13 +340,15 @@ export function TaskManager() {
variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
>
类型:{task.type}
{t('taskType')}
{task.type}
</Badge>
<Badge
variant='outline'
className='text-[9px] h-4.5 bg-background/50 font-mono text-muted-foreground border-border/50 px-1'
>
重试:{task.max_retry}
{t('taskRetry')}
{task.max_retry}
</Badge>
</div>
</div>
@@ -351,7 +360,7 @@ export function TaskManager() {
onClick={() => openDispatchDialog(task.type)}
>
<Play className='size-3 mr-1' />
立即执行
{t('executeNow')}
</Button>
</div>
</div>
@@ -365,15 +374,13 @@ export function TaskManager() {
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>下发任务</DialogTitle>
<DialogDescription>
配置任务参数并启动,任务将在后台异步执行。
</DialogDescription>
<DialogTitle>{t('dispatchTask')}</DialogTitle>
<DialogDescription>{t('dispatchTaskDesc')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label>执行任务</Label>
<Label>{t('executeTask')}</Label>
<div className='flex items-center gap-2 p-2 rounded-lg bg-muted/50'>
{(() => {
const meta = getSelectedTaskMeta();
@@ -403,20 +410,20 @@ export function TaskManager() {
return (
<>
<div className='grid gap-2'>
<Label>开始时间</Label>
<Label>{t('startTime')}</Label>
<DatePickerWithTime
date={startTime}
setDate={setStartTime}
/>
<p className='text-xs text-muted-foreground'>
选择同步的起始时间点
{t('startTimeDesc')}
</p>
</div>
<div className='grid gap-2'>
<Label>结束时间</Label>
<Label>{t('endTime')}</Label>
<DatePickerWithTime date={endTime} setDate={setEndTime} />
<p className='text-xs text-muted-foreground'>
选择同步的结束时间点
{t('endTimeDesc')}
</p>
</div>
</>
@@ -474,8 +481,8 @@ export function TaskManager() {
/>
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? '开启'
: '关闭'}
? t('paramOn')
: t('paramOff')}
</span>
</div>
) : (
@@ -513,7 +520,7 @@ export function TaskManager() {
return (
<div className='flex items-center gap-2 text-sm text-muted-foreground bg-muted/50 p-3 rounded-md border border-dashed'>
<Info className='h-4 w-4' />
<span>此任务无需额外配置参数,确认后将直接下发。</span>
<span>{t('noParamsHint')}</span>
</div>
);
}
@@ -528,7 +535,7 @@ export function TaskManager() {
disabled={dispatching}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
onClick={handleDispatch}
@@ -540,7 +547,7 @@ export function TaskManager() {
) : (
<Play className='size-3' />
)}
{dispatching ? '下发中' : '启动'}
{dispatching ? t('dispatching') : t('start')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -39,6 +40,7 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
import { EmptyStateWithBorder } from '@/components/layout/empty';
export function TaskSchedulesManager() {
const t = useTranslations('admin.tasks');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [schedules, setSchedules] = useState<Schedule[]>([]);
@@ -73,11 +75,13 @@ export function TaskSchedulesManager() {
setSchedules(schedulesData || []);
setTaskTypes(taskTypesData || []);
} catch (err) {
setError(err instanceof Error ? err : new Error('加载定时任务配置失败'));
setError(
err instanceof Error ? err : new Error(t('loadSchedulesFailed')),
);
} finally {
setLoading(false);
}
}, []);
}, [t]);
useEffect(() => {
fetchData();
@@ -130,12 +134,12 @@ export function TaskSchedulesManager() {
);
toast.success(
updated.is_active
? `已启用定时任务:${updated.name}`
: `已停用定时任务:${updated.name}`,
? t('scheduleEnabled', { name: updated.name })
: t('scheduleDisabled', { name: updated.name }),
);
} catch (err) {
toast.error('修改任务状态失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('changeScheduleStatusFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
}
};
@@ -161,11 +165,11 @@ export function TaskSchedulesManager() {
const handleSubmit = async () => {
if (!name.trim()) {
toast.error('任务名称不能为空');
toast.error(t('scheduleNameRequired'));
return;
}
if (!cron.trim()) {
toast.error('Cron 表达式不能为空');
toast.error(t('cronRequired'));
return;
}
@@ -203,7 +207,7 @@ export function TaskSchedulesManager() {
setSchedules((prev) =>
prev.map((s) => (s.id === editingSchedule.id ? updated : s)),
);
toast.success('定时任务更新成功');
toast.success(t('scheduleUpdateSuccess'));
} else {
const req: CreateScheduleRequest = {
name,
@@ -214,13 +218,13 @@ export function TaskSchedulesManager() {
};
const created = await services.adminTask.createSchedule(req);
setSchedules((prev) => [created, ...prev]);
toast.success('定时任务创建成功');
toast.success(t('scheduleCreateSuccess'));
}
setDialogOpen(false);
} catch (err) {
toast.error('提交失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('submitFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setSubmitLoading(false);
@@ -238,11 +242,11 @@ export function TaskSchedulesManager() {
setDeleteLoading(true);
await services.adminTask.deleteSchedule(deletingId);
setSchedules((prev) => prev.filter((s) => s.id !== deletingId));
toast.success('定时任务删除成功');
toast.success(t('scheduleDeleteSuccess'));
setDeleteOpen(false);
} catch (err) {
toast.error('删除失败', {
description: err instanceof Error ? err.message : '未知错误',
toast.error(t('deleteFailed'), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setDeleteLoading(false);
@@ -274,7 +278,7 @@ export function TaskSchedulesManager() {
) : (
<RefreshCw className='size-4' />
)}
刷新
{t('refresh')}
</Button>
<Button
size='sm'
@@ -283,7 +287,7 @@ export function TaskSchedulesManager() {
variant={'secondary'}
>
<Plus className='size-4 mr-1' />
新增定时任务
{t('addSchedule')}
</Button>
</div>
</div>
@@ -299,28 +303,31 @@ export function TaskSchedulesManager() {
) : loading && schedules.length === 0 ? (
<LoadingStateWithBorder
icon={Clock}
description='加载定时任务配置中...'
description={t('loadingSchedules')}
/>
) : schedules.length === 0 ? (
<EmptyStateWithBorder
icon={Clock}
description='暂无定时任务配置,点击上方按钮新增'
/>
<EmptyStateWithBorder icon={Clock} description={t('noSchedules')} />
) : (
<div className='rounded-lg border bg-card'>
<Table className='min-w-[800px]'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='w-[180px]'>任务名称</TableHead>
<TableHead className='w-[160px]'>关联异步任务</TableHead>
<TableHead className='w-[120px]'>Cron 表达式</TableHead>
<TableHead className='w-[180px]'>
{t('colScheduleName')}
</TableHead>
<TableHead className='w-[160px]'>
{t('colAssociatedTask')}
</TableHead>
<TableHead className='w-[120px]'>{t('colCron')}</TableHead>
<TableHead className='min-w-[200px]'>
执行参数 (Payload)
{t('colPayload')}
</TableHead>
<TableHead className='w-[100px] text-center'>
启用状态
{t('colEnabled')}
</TableHead>
<TableHead className='w-[120px] text-right'>
{t('colActions')}
</TableHead>
<TableHead className='w-[120px] text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -385,19 +392,17 @@ export function TaskSchedulesManager() {
<DialogContent className='sm:max-w-[500px]'>
<DialogHeader>
<DialogTitle>
{editingSchedule ? '修改定时任务' : '新增定时任务'}
{editingSchedule ? t('editSchedule') : t('addSchedule')}
</DialogTitle>
<DialogDescription>
配置定时任务的调度参数和运行载荷。
</DialogDescription>
<DialogDescription>{t('scheduleDialogDesc')}</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='sched-name'>任务名称</Label>
<Label htmlFor='sched-name'>{t('scheduleName')}</Label>
<Input
id='sched-name'
placeholder='例如:每日数据清理'
placeholder={t('scheduleNamePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
className='text-xs'
@@ -405,7 +410,7 @@ export function TaskSchedulesManager() {
</div>
<div className='grid gap-2'>
<Label htmlFor='sched-type'>关联异步任务</Label>
<Label htmlFor='sched-type'>{t('associatedTask')}</Label>
<select
id='sched-type'
value={selectedTaskType}
@@ -422,24 +427,24 @@ export function TaskSchedulesManager() {
</div>
<div className='grid gap-2'>
<Label htmlFor='sched-cron'>Cron 表达式</Label>
<Label htmlFor='sched-cron'>{t('cronExpression')}</Label>
<Input
id='sched-cron'
placeholder='e.g. 0 */2 * * * 或 @daily'
placeholder={t('cronPlaceholder')}
value={cron}
onChange={(e) => setCron(e.target.value)}
className='text-xs font-mono'
/>
<p className='text-[10px] text-muted-foreground'>
使用标准 5 位 Cron 字段格式(分钟、小时、日期、月份、星期几)。
{t('cronHint')}
</p>
</div>
<div className='flex items-center justify-between rounded-lg border p-3'>
<div className='space-y-0.5'>
<Label htmlFor='sched-active'>启用状态</Label>
<Label htmlFor='sched-active'>{t('scheduleActive')}</Label>
<p className='text-[10px] text-muted-foreground'>
决定此定时任务是否会定时触发执行。
{t('scheduleActiveDesc')}
</p>
</div>
<Switch
@@ -459,14 +464,14 @@ export function TaskSchedulesManager() {
return (
<div className='flex items-center gap-2 text-xs text-muted-foreground bg-muted/40 p-3 rounded-md border border-dashed'>
<Info className='h-4 w-4 shrink-0' />
<span>所选任务类型不需要运行参数参数。</span>
<span>{t('noParamsRequired')}</span>
</div>
);
}
return (
<div className='space-y-4 pt-2 border-t'>
<Label className='text-xs font-semibold'>
运行参数配置 (Payload)
{t('payloadConfig')}
</Label>
{targetTask.params.map((param) => (
<div
@@ -509,8 +514,8 @@ export function TaskSchedulesManager() {
/>
<span className='text-xs text-muted-foreground'>
{paramValues[param.name] === 'true'
? '开启'
: '关闭'}
? t('paramOn')
: t('paramOff')}
</span>
</div>
) : (
@@ -547,7 +552,7 @@ export function TaskSchedulesManager() {
disabled={submitLoading}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
onClick={handleSubmit}
@@ -555,7 +560,7 @@ export function TaskSchedulesManager() {
className='h-8 text-xs'
>
{submitLoading && <Spinner className='size-3 mr-1' />}
{editingSchedule ? '保存修改' : '确认创建'}
{editingSchedule ? t('saveChanges') : t('confirmCreate')}
</Button>
</DialogFooter>
</DialogContent>
@@ -565,10 +570,8 @@ export function TaskSchedulesManager() {
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className='sm:max-w-[400px]'>
<DialogHeader>
<DialogTitle>删除定时任务</DialogTitle>
<DialogDescription>
确定要删除此定时任务吗?该操作不可撤销,且会立即取消其在调度器中的调度计划。
</DialogDescription>
<DialogTitle>{t('deleteScheduleTitle')}</DialogTitle>
<DialogDescription>{t('deleteScheduleDesc')}</DialogDescription>
</DialogHeader>
<DialogFooter className='gap-2 sm:gap-0'>
<Button
@@ -577,7 +580,7 @@ export function TaskSchedulesManager() {
disabled={deleteLoading}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
variant='destructive'
@@ -586,7 +589,7 @@ export function TaskSchedulesManager() {
className='h-8 text-xs'
>
{deleteLoading && <Spinner className='size-3 mr-1' />}
确认删除
{t('confirmDelete')}
</Button>
</DialogFooter>
</DialogContent>
+8 -4
View File
@@ -2,6 +2,7 @@
import { Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { TaskManager } from './components/task-manager';
import { TaskSchedulesManager } from './components/task-schedules';
@@ -10,6 +11,7 @@ import { Spinner } from '@/components/ui/spinner';
import { Layers } from 'lucide-react';
function TasksPageContent() {
const t = useTranslations('admin.tasks');
const searchParams = useSearchParams();
const router = useRouter();
const activeTab = searchParams.get('tab') || 'tasks';
@@ -22,7 +24,9 @@ function TasksPageContent() {
<div className='flex items-center gap-2'>
<Layers className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>任务管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
@@ -36,19 +40,19 @@ function TasksPageContent() {
value='tasks'
className='px-0 pb-2 text-xs font-semibold'
>
任务管理
{t('tabTasks')}
</TabsTrigger>
<TabsTrigger
value='schedules'
className='px-0 pb-2 text-xs font-semibold'
>
定时任务
{t('tabSchedules')}
</TabsTrigger>
<TabsTrigger
value='executions'
className='px-0 pb-2 text-xs font-semibold'
>
任务日志
{t('tabExecutions')}
</TabsTrigger>
</TabsList>
<TabsContent value='tasks' className='space-y-4 outline-none'>
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -31,6 +32,7 @@ export function CreateUserModal({
isOpen: boolean;
onClose: () => void;
}) {
const t = useTranslations('admin.users');
const { createUser } = useAdminUsers();
const [form, setForm] = useState<CreateUserRequest>(emptyForm);
const [saving, setSaving] = useState(false);
@@ -48,21 +50,21 @@ export function CreateUserModal({
const validate = () => {
const newErrors: Record<string, string> = {};
if (!form.username.trim()) {
newErrors.username = '用户名不能为空';
newErrors.username = t('validation.usernameRequired');
} else if (form.username.trim().length < 3) {
newErrors.username = '用户名长度不能少于 3 位';
newErrors.username = t('validation.usernameTooShort');
}
if (!form.email.trim()) {
newErrors.email = '邮箱不能为空';
newErrors.email = t('validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = '邮箱格式不正确';
newErrors.email = t('validation.emailInvalid');
}
if (!form.password) {
newErrors.password = '密码不能为空';
newErrors.password = t('validation.passwordRequired');
} else if (form.password.length < 8) {
newErrors.password = '密码长度不能少于 8 位';
newErrors.password = t('validation.passwordTooShort');
}
setErrors(newErrors);
@@ -93,22 +95,20 @@ export function CreateUserModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>新增用户</DialogTitle>
<DialogDescription>
直接创建一个本地密码登录的新账户。
</DialogDescription>
<DialogTitle>{t('createTitle')}</DialogTitle>
<DialogDescription>{t('createDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className='space-y-4 pt-2'>
<div className='space-y-1.5'>
<Label htmlFor='username'>用户名</Label>
<Label htmlFor='username'>{t('username')}</Label>
<Input
id='username'
value={form.username}
onChange={(e) =>
setForm((prev) => ({ ...prev, username: e.target.value }))
}
placeholder='请输入用户名 (至少 3 位)'
placeholder={t('usernamePlaceholder')}
/>
{errors.username && (
<p className='text-xs text-destructive'>{errors.username}</p>
@@ -116,19 +116,19 @@ export function CreateUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label>
<Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
<Input
id='nickname'
value={form.nickname}
onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value }))
}
placeholder='请输入昵称'
placeholder={t('nicknamePlaceholder')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label>
<Label htmlFor='email'>{t('email')}</Label>
<Input
id='email'
type='email'
@@ -136,7 +136,7 @@ export function CreateUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value }))
}
placeholder='请输入邮箱地址'
placeholder={t('emailPlaceholder')}
/>
{errors.email && (
<p className='text-xs text-destructive'>{errors.email}</p>
@@ -144,7 +144,7 @@ export function CreateUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='password'>密码</Label>
<Label htmlFor='password'>{t('password')}</Label>
<Input
id='password'
type='password'
@@ -152,7 +152,7 @@ export function CreateUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
placeholder='请输入登录密码 (至少 8 位)'
placeholder={t('passwordPlaceholder')}
/>
{errors.password && (
<p className='text-xs text-destructive'>{errors.password}</p>
@@ -161,9 +161,11 @@ export function CreateUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>启用账户</div>
<div className='font-medium text-sm'>
{t('enableAccountLabel')}
</div>
<div className='text-xs text-muted-foreground'>
禁用后此账号将无法登录系统。
{t('enableAccountDesc')}
</div>
</div>
<Switch
@@ -176,9 +178,9 @@ export function CreateUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>管理员权限</div>
<div className='font-medium text-sm'>{t('adminPermission')}</div>
<div className='text-xs text-muted-foreground'>
开启后此账号将拥有后台管理权限。
{t('adminPermissionDesc')}
</div>
</div>
<Switch
@@ -191,10 +193,10 @@ export function CreateUserModal({
<div className='flex justify-end gap-2 pt-2 border-t mt-2'>
<Button variant='outline' type='button' onClick={onClose}>
取消
{t('cancel')}
</Button>
<Button type='submit' disabled={saving} variant='secondary'>
{saving ? '创建中...' : '创建'}
{saving ? t('creating') : t('create')}
</Button>
</div>
</form>
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -43,6 +44,7 @@ export function EditUserModal({
onClose: () => void;
user: AdminUser | null;
}) {
const t = useTranslations('admin.users');
const { updateUser, deleteUser, getUserDetail } = useAdminUsers();
const { user: currentUser } = useAuth();
@@ -96,13 +98,13 @@ export function EditUserModal({
const newErrors: Record<string, string> = {};
if (!form.email.trim()) {
newErrors.email = '邮箱不能为空';
newErrors.email = t('validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = '邮箱格式不正确';
newErrors.email = t('validation.emailInvalid');
}
if (form.password && form.password.length < 8) {
newErrors.password = '密码长度不能少于 8 位';
newErrors.password = t('validation.passwordTooShort');
}
setErrors(newErrors);
@@ -150,10 +152,8 @@ export function EditUserModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>编辑用户</DialogTitle>
<DialogDescription>
修改用户档案、授予/撤销管理权限或重置其密码。
</DialogDescription>
<DialogTitle>{t('editTitle')}</DialogTitle>
<DialogDescription>{t('editDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSave} className='space-y-4 pt-2'>
@@ -162,7 +162,7 @@ export function EditUserModal({
htmlFor='username'
className='text-xs text-muted-foreground'
>
用户名 (不可修改)
{t('usernameImmutable')}
</Label>
<Input
id='username'
@@ -173,19 +173,19 @@ export function EditUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='nickname'>昵称 (选填)</Label>
<Label htmlFor='nickname'>{t('nicknameOptional')}</Label>
<Input
id='nickname'
value={form.nickname}
onChange={(e) =>
setForm((prev) => ({ ...prev, nickname: e.target.value }))
}
placeholder='请输入昵称'
placeholder={t('nicknamePlaceholder')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='email'>邮箱</Label>
<Label htmlFor='email'>{t('email')}</Label>
<Input
id='email'
type='email'
@@ -193,7 +193,7 @@ export function EditUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, email: e.target.value }))
}
placeholder='请输入邮箱地址'
placeholder={t('emailPlaceholder')}
/>
{errors.email && (
<p className='text-xs text-destructive'>{errors.email}</p>
@@ -201,7 +201,7 @@ export function EditUserModal({
</div>
<div className='space-y-1.5'>
<Label htmlFor='password'>重置密码 (选填)</Label>
<Label htmlFor='password'>{t('resetPasswordOptional')}</Label>
<Input
id='password'
type='password'
@@ -209,7 +209,7 @@ export function EditUserModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, password: e.target.value }))
}
placeholder='留空表示保持当前密码,输入则重置 (至少 8 位)'
placeholder={t('resetPasswordPlaceholder')}
/>
{errors.password && (
<p className='text-xs text-destructive'>{errors.password}</p>
@@ -218,11 +218,13 @@ export function EditUserModal({
<div className='flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10'>
<div>
<div className='font-medium text-sm'>管理员权限</div>
<div className='font-medium text-sm'>
{t('adminPermission')}
</div>
<div className='text-xs text-muted-foreground'>
{isSelf
? '不能撤销当前登录用户的管理员权限。'
: '开启后此账号将拥有后台管理权限。'}
? t('cannotRevokeSelfAdmin')
: t('adminPermissionDesc')}
</div>
</div>
<Switch
@@ -255,7 +257,7 @@ export function EditUserModal({
onClick={() => setShowDeleteConfirm(true)}
disabled={saving}
>
删除用户
{t('deleteUser')}
</Button>
)}
</div>
@@ -266,10 +268,10 @@ export function EditUserModal({
onClick={onClose}
disabled={saving}
>
取消
{t('cancel')}
</Button>
<Button type='submit' disabled={saving} variant='secondary'>
{saving ? '保存中...' : '保存'}
{saving ? t('saving') : t('save')}
</Button>
</div>
</div>
@@ -280,14 +282,15 @@ export function EditUserModal({
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
<AlertDialogTitle>{t('deleteConfirmTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除用户 {user.nickname || user.username}{' '}
吗?该操作会移除用户账号,删除后无法撤销。
{t('deleteConfirmDesc', { name: user.nickname || user.username })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={saving}>
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
@@ -296,7 +299,7 @@ export function EditUserModal({
disabled={saving}
>
{saving && <Loader2 className='size-3 animate-spin mr-1' />}
确认删除
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import {
Globe,
Loader2,
@@ -33,13 +34,14 @@ export function UserDetailSheet({
detailLoading,
onStatusToggle,
}: UserDetailSheetProps) {
const t = useTranslations('admin.users');
const displayValue = (value?: string) =>
value && value.trim() ? value : '-';
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent className='sm:max-w-[400px] w-full p-0 flex flex-col gap-0'>
<SheetTitle className='px-5 py-3'>用户档案</SheetTitle>
<SheetTitle className='px-5 py-3'>{t('userProfile')}</SheetTitle>
{selectedUser && (
<>
@@ -79,14 +81,14 @@ export function UserDetailSheet({
{detailLoading && (
<div className='flex items-center gap-1 text-[10px] text-muted-foreground'>
<Loader2 className='size-3 animate-spin' />
正在刷新详情
{t('refreshingDetail')}
</div>
)}
<div className='gap-4 w-full max-w-[240px] mt-1 pt-4 border-t border-border/50'>
<div className='flex flex-col gap-0.5'>
<span className='text-[9px] uppercase tracking-widest text-muted-foreground font-medium'>
注册时间
{t('registeredAt')}
</span>
<span className='font-mono text-xs font-semibold'>
{
@@ -103,13 +105,13 @@ export function UserDetailSheet({
<div className='p-6 space-y-6'>
<div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
个人资料
{t('personalInfo')}
</h4>
<div className='rounded-lg border divide-y bg-background/50'>
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Mail className='size-3' />
邮箱
{t('email')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.email)}
@@ -118,7 +120,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Smartphone className='size-3' />
手机
{t('phone')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.phone)}
@@ -129,7 +131,7 @@ export function UserDetailSheet({
<span className='size-3 flex items-center justify-center font-bold text-[9px]'>
⚧
</span>
性别
{t('gender')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.gender)}
@@ -138,7 +140,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<MapPin className='size-3' />
所在地
{t('location')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.location)}
@@ -147,7 +149,7 @@ export function UserDetailSheet({
<div className='flex items-center justify-between gap-4 p-3.5 text-sm'>
<span className='flex items-center gap-2 text-[10px] text-muted-foreground'>
<Globe className='size-3' />
网站
{t('website')}
</span>
<span className='min-w-0 truncate text-right text-[10px]'>
{displayValue(selectedUser.website)}
@@ -155,7 +157,7 @@ export function UserDetailSheet({
</div>
<div className='flex flex-col gap-2 p-3.5 text-sm'>
<span className='text-[10px] text-muted-foreground'>
简介
{t('bio')}
</span>
<span className='break-words text-[10px] leading-5'>
{displayValue(selectedUser.bio)}
@@ -166,40 +168,44 @@ export function UserDetailSheet({
<div className='space-y-4'>
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider px-1'>
系统记录
{t('systemRecords')}
</h4>
<div className='rounded-lg border divide-y bg-background/50'>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>账户状态</span>
<span className='text-[10px]'>
{t('accountStatus')}
</span>
<Badge
variant={
selectedUser.is_active ? 'secondary' : 'outline'
}
className='text-[10px]'
>
{selectedUser.is_active ? '正常' : '禁用'}
{selectedUser.is_active
? t('statusActive')
: t('statusDisabled')}
</Badge>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>管理员</span>
<span className='text-[10px]'>{t('isAdmin')}</span>
<span className='font-mono text-[10px]'>
{selectedUser.is_admin ? '是' : '否'}
{selectedUser.is_admin ? t('yes') : t('no')}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>最后登录</span>
<span className='text-[10px]'>{t('lastLogin')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.last_login_at)}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>注册时间</span>
<span className='text-[10px]'>{t('registeredAt')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.created_at)}
</span>
</div>
<div className='flex items-center justify-between p-3.5 text-sm'>
<span className='text-[10px]'>最后更新</span>
<span className='text-[10px]'>{t('lastUpdate')}</span>
<span className='font-mono text-[10px]'>
{formatDateTime(selectedUser.updated_at)}
</span>
@@ -219,12 +225,12 @@ export function UserDetailSheet({
{selectedUser.is_active ? (
<>
<ShieldCheck className='size-3 mr-1' />
封禁账户
{t('banAccount')}
</>
) : (
<>
<UserCheck className='size-3 mr-1' />
解除封禁
{t('unbanAccount')}
</>
)}
</Button>
@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import {
ChevronDown,
ChevronLeft,
@@ -24,6 +25,7 @@ import { useAdminUsers } from '@/contexts/admin-users-context';
import { cn } from '@/lib/utils';
export function UserFilterBar() {
const t = useTranslations('admin.users');
const {
total,
loading,
@@ -61,7 +63,7 @@ export function UserFilterBar() {
)}
>
<Search className='size-3 mr-1' />
搜索
{t('search')}
{hasSearchFilter && (
<>
<Separator orientation='vertical' className='mx-1' />
@@ -79,19 +81,19 @@ export function UserFilterBar() {
<div className='space-y-2.5'>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入用户 ID...'
placeholder={t('searchUserIdPlaceholder')}
value={searchUserId}
onChange={(e) => setSearchUserId(e.target.value)}
/>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入 username...'
placeholder={t('searchUsernamePlaceholder')}
value={searchUsername}
onChange={(e) => setSearchUsername(e.target.value)}
/>
<input
className='w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background'
placeholder='输入邮箱...'
placeholder={t('searchEmailPlaceholder')}
value={searchEmail}
onChange={(e) => setSearchEmail(e.target.value)}
/>
@@ -106,7 +108,7 @@ export function UserFilterBar() {
setSearchEmail('');
}}
>
清除
{t('clear')}
</Button>
)}
</div>
@@ -124,7 +126,7 @@ export function UserFilterBar() {
)}
>
<Filter className='size-3' />
状态
{t('status')}
{statusFilter !== 'all' && (
<>
<Separator orientation='vertical' className='mx-1' />
@@ -153,7 +155,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>全部状态</span>
<span className='text-xs'>{t('statusAll')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
@@ -169,7 +171,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>正常</span>
<span className='text-xs'>{t('statusActive')}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => {
@@ -185,7 +187,7 @@ export function UserFilterBar() {
: 'opacity-50',
)}
/>
<span className='text-xs'>禁用</span>
<span className='text-xs'>{t('statusDisabled')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -205,7 +207,7 @@ export function UserFilterBar() {
className='h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground'
>
<X className='size-3' />
清空筛选
{t('clearFilters')}
</Button>
</>
)}
@@ -215,7 +217,7 @@ export function UserFilterBar() {
<div className='flex items-center gap-1.5 self-end lg:self-auto'>
<span className='text-[10px] text-muted-foreground whitespace-nowrap'>
{total} 条记录
{t('totalRecords', { count: total })}
</span>
<div className='flex items-center border border-dashed rounded-md shadow-none'>
<Button
@@ -249,7 +251,7 @@ export function UserFilterBar() {
className='h-6 border-dashed text-[10px] px-2 font-mono shadow-none'
disabled={loading}
>
{pageSize}条/页
{t('perPage', { count: pageSize })}
<ChevronDown className='size-3 opacity-50' />
</Button>
</DropdownMenuTrigger>
@@ -263,7 +265,7 @@ export function UserFilterBar() {
pageSize === size && 'bg-accent',
)}
>
{size}条/页
{t('perPage', { count: size })}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -275,7 +277,7 @@ export function UserFilterBar() {
className='h-6 w-6 border-dashed shadow-none'
onClick={() => fetchUsers(true)}
disabled={loading}
title='刷新数据'
title={t('refreshData')}
>
<Loader2 className={cn('size-3', loading && 'animate-spin')} />
</Button>
+19 -15
View File
@@ -1,6 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
@@ -33,6 +34,7 @@ import { UserDetailSheet } from './components/user-detail-sheet';
import { EditUserModal } from './components/edit-user-modal';
export default function UsersPage() {
const t = useTranslations('admin.users');
const { users, loading, error, fetchUsers, getUserDetail, updateUserStatus } =
useAdminUsers();
@@ -78,7 +80,9 @@ export default function UsersPage() {
<div className='flex items-center gap-2'>
<UserRound className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>用户管理</h1>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('pageTitle')}
</h1>
</div>
</div>
<Button
@@ -88,7 +92,7 @@ export default function UsersPage() {
onClick={() => setCreateModalOpen(true)}
>
<Plus className='size-3.5 mr-1' />
新增用户
{t('createUser')}
</Button>
</div>
@@ -104,9 +108,9 @@ export default function UsersPage() {
/>
</div>
) : loading && users.length === 0 ? (
<LoadingStateWithBorder icon={Layers} description='加载用户列表中...' />
<LoadingStateWithBorder icon={Layers} description={t('loadingUsers')} />
) : users.length === 0 ? (
<EmptyStateWithBorder icon={UserX} description='暂无用户数据' />
<EmptyStateWithBorder icon={UserX} description={t('noUsers')} />
) : (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<TooltipProvider delayDuration={0}>
@@ -117,22 +121,22 @@ export default function UsersPage() {
ID
</TableHead>
<TableHead className='w-[120px] whitespace-nowrap py-2 h-8'>
用户
{t('colUser')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[160px] py-2 h-8 pl-4'>
邮箱
{t('colEmail')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次登陆
{t('colLastLogin')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
注册时间
{t('colRegisteredAt')}
</TableHead>
<TableHead className='whitespace-nowrap min-w-[140px] py-2 h-8'>
上次更新
{t('colLastUpdate')}
</TableHead>
<TableHead className='sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8'>
操作
{t('colActions')}
</TableHead>
</TableRow>
</TableHeader>
@@ -211,10 +215,10 @@ export default function UsersPage() {
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
{user.is_admin
? '管理员账户'
? t('adminAccount')
: user.is_active
? '禁用账户'
: '启用账户'}
? t('disableAccount')
: t('enableAccount')}
</TooltipContent>
</Tooltip>
@@ -230,7 +234,7 @@ export default function UsersPage() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看详情
{t('viewDetail')}
</TooltipContent>
</Tooltip>
@@ -246,7 +250,7 @@ export default function UsersPage() {
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
编辑用户
{t('editUser')}
</TooltipContent>
</Tooltip>
</div>
+3 -1
View File
@@ -6,8 +6,10 @@ import { motion } from 'motion/react';
import { RequireAuth } from '@/components/auth/require-auth';
import { UserFileManager } from '@/components/common/user/file-manager';
import { FolderOpen } from 'lucide-react';
import { useTranslations } from 'next-intl';
export default function UserFilesPage() {
const t = useTranslations('files');
return (
<RequireAuth>
<motion.div
@@ -22,7 +24,7 @@ export default function UserFilesPage() {
<FolderOpen className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
我的文件
{t('myFiles')}
</h1>
</div>
</div>
+5 -5
View File
@@ -5,6 +5,7 @@ import Link from 'next/link';
import { motion } from 'motion/react';
import { ArrowLeft, Home } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'next-intl';
/**
* 404 Not Found Page
@@ -12,6 +13,7 @@ import { Button } from '@/components/ui/button';
*/
export default function NotFound() {
const router = useRouter();
const t = useTranslations('notFound');
return (
<div className='relative min-h-screen w-full flex flex-col items-center justify-center bg-background overflow-hidden selection:bg-primary/20'>
@@ -43,9 +45,7 @@ export default function NotFound() {
className='space-y-8'
>
<p className='text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed'>
抱歉,您访问的页面似已迷失在数字星云中。
<br />
请检查路径或返回安全地带。
{t('description')}
</p>
<div className='flex justify-center gap-4'>
@@ -56,7 +56,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300'
>
<ArrowLeft className='size-3 opacity-70' />
上一页
{t('previousPage')}
</Button>
<Link href='/'>
@@ -66,7 +66,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300'
>
<Home className='size-3' />
首页
{t('home')}
</Button>
</Link>
</div>
+14 -6
View File
@@ -9,6 +9,7 @@ import {
ShieldQuestion,
} from 'lucide-react';
import { getCapToken } from '@/lib/cap-solver';
import { useTranslations } from 'next-intl';
type CapStatus = 'idle' | 'solving' | 'solved' | 'error';
@@ -38,6 +39,7 @@ export function CapWidget({
const [status, setStatus] = useState<CapStatus>('idle');
const [errorMsg, setErrorMsg] = useState('');
const solving = useRef(false);
const t = useTranslations('auth.capWidget');
const solve = async () => {
if (solving.current) return;
@@ -76,7 +78,9 @@ export function CapWidget({
onClick={() => void solve()}
>
<ShieldQuestion className='size-4 shrink-0 text-muted-foreground' />
<span className='flex-1 text-muted-foreground'>点击开始人机验证</span>
<span className='flex-1 text-muted-foreground'>
{t('clickToStart')}
</span>
</button>
)}
@@ -84,14 +88,16 @@ export function CapWidget({
{status === 'idle' && autoStart && (
<>
<ShieldAlert className='size-4 shrink-0 text-muted-foreground' />
<span className='text-muted-foreground'>等待人机验证…</span>
<span className='text-muted-foreground'>
{t('waitingVerification')}
</span>
</>
)}
{status === 'solving' && (
<>
<Loader2 className='size-4 shrink-0 animate-spin text-primary' />
<span className='text-muted-foreground'>正在完成人机验证…</span>
<span className='text-muted-foreground'>{t('verifying')}</span>
</>
)}
@@ -99,7 +105,7 @@ export function CapWidget({
<>
<CheckCircle2 className='size-4 shrink-0 text-green-500' />
<span className='text-green-600 dark:text-green-400'>
人机验证通过
{t('verificationPassed')}
</span>
<ShieldCheck className='ml-auto size-4 shrink-0 text-green-500' />
</>
@@ -113,9 +119,11 @@ export function CapWidget({
>
<ShieldAlert className='size-4 shrink-0 text-destructive' />
<span className='flex-1 text-destructive'>
{errorMsg || '人机验证失败'}
{errorMsg || t('verificationFailed')}
</span>
<span className='text-xs text-muted-foreground underline'>
{t('retry')}
</span>
<span className='text-xs text-muted-foreground underline'>重试</span>
</button>
)}
</div>
+1 -1
View File
@@ -114,7 +114,7 @@ export function RegisterForm() {
toast.error(t('closed'));
router.replace('/login');
}
}, [publicConfigQuery.isSuccess, registrationEnabled, router]);
}, [publicConfigQuery.isSuccess, registrationEnabled, router, t]);
const registerMutation = useMutation({
mutationFn: (req: RegisterRequest) => {
+3 -1
View File
@@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react';
import { ErrorPage } from '@/components/layout/error';
import { useUser } from '@/contexts/user-context';
import { useTranslations } from 'next-intl';
type RequireAuthProps = {
children: ReactNode;
@@ -49,6 +50,7 @@ type RequireAdminAuthProps = {
/** Guards admin routes after the shared shell has rendered. */
export function RequireAdminAuth({ children }: RequireAdminAuthProps) {
const { user, loading } = useUser();
const t = useTranslations('auth.requireAuth');
if (loading) {
return (
@@ -59,7 +61,7 @@ export function RequireAdminAuth({ children }: RequireAdminAuthProps) {
}
if (!user?.is_admin) {
return <ErrorPage title='访问被拒绝' message='您没有权限访问此页面' />;
return <ErrorPage title={t('accessDenied')} message={t('noPermission')} />;
}
return <>{children}</>;
+421 -408
View File
@@ -11,62 +11,61 @@ import {
export const DOCS_LAST_UPDATED = '2026-06-07';
/**
* ------------------------------------------------------------------
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: 'api-specs',
title: '1. 接口规范与鉴权说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明
</p>
</div>
type TFn = (key: string) => string;
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.1 统一响应格式
</h3>
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>字段</DocsTableHead>
<DocsTableHead className='w-[100px]'>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
错误信息。请求成功时为空字符串
`&quot;&quot;`,失败时包含错误详情描述。
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>data</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>
接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
export function getApiSections(t: TFn): PolicySection[] {
return [
{
value: 'api-specs',
title: t('api.section1.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{t('api.section1.desc')}
</p>
</div>
<p className='mt-2'>成功响应示例:</p>
<CodeBlock
code={`{
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{t('api.section1.responseFormat')}
</h3>
<p>{t('api.section1.responseFormatDesc')}</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{t('api.field')}
</DocsTableHead>
<DocsTableHead className='w-[100px]'>
{t('api.type')}
</DocsTableHead>
<DocsTableHead>{t('api.description')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.section1.errorMsgDesc')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
data
</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>{t('api.section1.dataDesc')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className='mt-2'>{t('api.section1.successExample')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"id": 1,
@@ -74,263 +73,277 @@ export const apiSections: PolicySection[] = [
"nickname": "Ryan"
}
}`}
language='json'
/>
language='json'
/>
<p className='mt-2'>失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
<p className='mt-2'>{t('api.section1.failExample')}</p>
<CodeBlock
code={`{
"error_msg": "${t('api.section1.failExampleErrorMsg')}",
"data": null
}`}
language='json'
/>
language='json'
/>
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.2 鉴权方式
</h3>
<p>
除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session
Cookie 会话保持登录。
</li>
<li>
<strong>AccessToken 令牌:</strong>
供后台调用或第三方应用集成使用。客户端生成 API
访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
支持携带令牌的请求头格式(二选一):
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{t('api.section1.authentication')}
</h3>
<p>{t('api.section1.authenticationDesc')}</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
<strong>{t('api.section1.sessionCredential')}</strong>
{t('api.section1.sessionCredentialDesc')}
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
<strong>{t('api.section1.accessTokenCredential')}</strong>
{t('api.section1.accessTokenCredentialDesc')}
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
{t('api.section1.supportedHeaders')}
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
</li>
</ul>
</div>
</div>
</div>
),
children: [
{ value: '1-1-response-format', title: '1.1 统一响应格式' },
{ value: '1-2-authentication', title: '1.2 鉴权方式' },
],
},
{
value: 'auth-apis',
title: '2. 用户与认证接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 用户注册
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>说明:</strong>注册本地账户(在后台注册开关开启状态下)。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
),
children: [
{
value: '1-1-response-format',
title: t('api.section1.responseFormat'),
},
{
value: '1-2-authentication',
title: t('api.section1.authentication'),
},
],
},
{
value: 'auth-apis',
title: t('api.section2.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('api.section2.register')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section2.registerDesc')}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{t('api.param')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{t('api.required')}
</DocsTableHead>
<DocsTableHead>{t('api.type')}</DocsTableHead>
<DocsTableHead>{t('api.description')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{t('api.yes')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.section2.usernameDesc')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{t('api.yes')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.section2.passwordDesc')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>{t('api.no')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.section2.nicknameDesc')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.2 密码登录
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>说明:</strong>通过常规用户名密码进行登录校验,成功后建立
Session Cookie 会话。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section2.passwordLogin')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section2.passwordLoginDesc')}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{t('api.param')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{t('api.required')}
</DocsTableHead>
<DocsTableHead>{t('api.type')}</DocsTableHead>
<DocsTableHead>{t('api.description')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{t('api.yes')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.username')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{t('api.yes')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{t('api.password')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.3 退出登录
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。
</p>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section2.logout')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section2.logoutDesc')}
</p>
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.4 获取个人资料
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>说明:</strong>获取当前登录账户的基本数据模型(包含
ID、角色、昵称等)。
</p>
</div>
),
children: [
{ value: '2-1-register', title: '2.1 用户注册' },
{ value: '2-2-login', title: '2.2 密码登录' },
{ value: '2-3-logout', title: '2.3 退出登录' },
{ value: '2-4-profile', title: '2.4 获取个人资料' },
],
},
{
value: 'token-apis',
title: '3. 个人访问令牌 (AccessToken) 接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
AccessToken 管理相关接口均要求通过 Session
登录后调用,支持普通用户权限。
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section2.getProfile')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section2.getProfileDesc')}
</p>
</div>
),
children: [
{ value: '2-1-register', title: t('api.section2.register') },
{ value: '2-2-login', title: t('api.section2.passwordLogin') },
{ value: '2-3-logout', title: t('api.section2.logout') },
{ value: '2-4-profile', title: t('api.section2.getProfile') },
],
},
{
value: 'token-apis',
title: t('api.section3.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
{t('api.section3.desc')}
</p>
</div>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 获取令牌列表
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>说明:</strong>
查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。
</p>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('api.section3.listTokens')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section3.listTokensDesc')}
</p>
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.2 新建访问令牌
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>参数:</strong>JSON Body{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "token名称", "is_admin": false}`}</code>
</p>
<p>
<strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文
Token,切勿遗失。
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>(可选,默认{' '}
<code className='bg-muted px-1 rounded'>false</code>
):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code> 端点。
</p>
<p className='mt-2'>成功返回样例:</p>
<CodeBlock
code={`{
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section3.createToken')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{t('api.param')}</strong>JSON Body{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "${t('api.section3.tokenName')}", "is_admin": false}`}</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section3.createTokenDesc')}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>(
{t('api.optional')},{t('api.default')}{' '}
<code className='bg-muted px-1 rounded'>false</code>
):{t('api.section3.isAdminDesc')}{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code>{' '}
{t('api.section3.endpoint')}.
</p>
<p className='mt-2'>{t('api.section3.successResponseExample')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"token": "at_628d022b7a95e26bcd8b29c9...",
@@ -344,76 +357,75 @@ export const apiSections: PolicySection[] = [
}
}
}`}
language='json'
/>
language='json'
/>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.3 撤销/删除令牌
</h3>
<p>
<strong>接口:</strong>DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>说明:</strong>通过 ID
物理删除对应访问令牌,该令牌将立即失效。
</p>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section3.deleteToken')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section3.deleteTokenDesc')}
</p>
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.4 轮换令牌密钥
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>说明:</strong>
轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文
Token,令牌名称与 ID 保持一致。
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: '3.1 获取令牌列表' },
{ value: '3-2-create-token', title: '3.2 新建访问令牌' },
{ value: '3-3-delete-token', title: '3.3 撤销/删除令牌' },
{ value: '3-4-rotate-token', title: '3.4 轮换令牌密钥' },
],
},
{
value: 'config-apis',
title: '4. 公共配置与管理接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
4.1 公共系统配置
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>说明:</strong>
无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。
</p>
<p className='mt-2'>返回数据结构样例:</p>
<CodeBlock
code={`{
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section3.rotateToken')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section3.rotateTokenDesc')}
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: t('api.section3.listTokens') },
{ value: '3-2-create-token', title: t('api.section3.createToken') },
{ value: '3-3-delete-token', title: t('api.section3.deleteToken') },
{ value: '3-4-rotate-token', title: t('api.section3.rotateToken') },
],
},
{
value: 'config-apis',
title: t('api.section4.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('api.section4.publicConfig')}
</h3>
<p>
<strong>{t('api.endpoint')}</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section4.publicConfigDesc')}
</p>
<p className='mt-2'>{t('api.section4.responseExample')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"site_name": "Wavelet",
@@ -423,50 +435,51 @@ export const apiSections: PolicySection[] = [
"oidc_login_enabled": "true"
}
}`}
language='json'
/>
language='json'
/>
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
4.2 系统配置项 CRUD (管理员)
</h3>
<p>
<strong>说明:</strong>用于在后台对 `system_configs`
配置进行动态变更,要求管理员权限会话调用。
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>获取配置列表:</strong>GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>新建配置项:</strong>POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>修改指定配置值:</strong>PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>删除配置项:</strong>DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{ value: '4-1-public-config', title: '4.1 公共系统配置' },
{ value: '4-2-admin-configs', title: '4.2 系统配置项 CRUD (管理员)' },
],
},
];
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{t('api.section4.adminConfigs')}
</h3>
<p>
<strong>{t('api.desc')}</strong>
{t('api.section4.adminConfigsDesc')}
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{t('api.section4.getConfigList')}</strong>GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>{t('api.section4.createConfig')}</strong>POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>{t('api.section4.updateConfig')}</strong>PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>{t('api.section4.deleteConfig')}</strong>DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{ value: '4-1-public-config', title: t('api.section4.publicConfig') },
{ value: '4-2-admin-configs', title: t('api.section4.adminConfigs') },
],
},
];
}
+220 -243
View File
@@ -1,254 +1,231 @@
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
/**
* ------------------------------------------------------------------
* 使用指南 (How To Use)
* ------------------------------------------------------------------
*/
export const howToUseSections: PolicySection[] = [
{
value: 'quick-start',
title: '1. 快速开始',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明
</p>
type TFn = (key: string) => string;
export function getHowToUseSections(t: TFn): PolicySection[] {
return [
{
value: 'quick-start',
title: t('howToUse.section1.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{t('howToUse.section1.desc')}
</p>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section1.architecture')}</strong>
{t('howToUse.section1.architectureDesc')}
</li>
<li>
<strong>{t('howToUse.section1.authSystem')}</strong>
{t('howToUse.section1.authSystemDesc')}
</li>
<li>
<strong>{t('howToUse.section1.accessToken')}</strong>
{t('howToUse.section1.accessTokenDesc')}
</li>
<li>
<strong>{t('howToUse.section1.observability')}</strong>
{t('howToUse.section1.observabilityDesc')}
</li>
</ul>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>架构底座:</strong>Go (Gin + GORM + Redis + Asynq) 后端 +
React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端
</li>
<li>
<strong>认证体系:</strong>支持本地常规账号密码注册登录 +
第三方自定义 OIDC (OAuth2) 认证源绑定
</li>
<li>
<strong>访问令牌:</strong>提供开发者个人 AccessToken (API
Key),用于通过 Http Header 鉴权直接调用系统 API
</li>
<li>
<strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry
全链路 Tracing 追踪
</li>
</ul>
</div>
),
},
{
value: 'auth-security',
title: '2. 身份认证与安全设置',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 常规账号密码认证
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>自主注册与密码登录:</strong>
支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
高强度加盐哈希存储。
</li>
<li>
<strong>系统开关控制:</strong>
管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。
</li>
</ul>
),
},
{
value: 'auth-security',
title: t('howToUse.section2.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section2.desc')}</p>
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('howToUse.section2.passwordAuth')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section2.selfRegister')}</strong>
{t('howToUse.section2.selfRegisterDesc')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
{t('howToUse.section2.bcryptDesc')}
</li>
<li>
<strong>{t('howToUse.section2.switchControl')}</strong>
{t('howToUse.section2.switchControlDesc')}
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.2 第三方 OIDC 认证源
</h3>
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>
进入 <strong>设置 / 个人资料</strong> 页面。
</li>
<li>
在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC
认证源直接触发 OAuth2 绑定流。
</li>
<li>
绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。
</li>
</ol>
</div>
),
children: [
{ value: '2-1-login', title: '2.1 常规账号密码认证' },
{ value: '2-2-oidc', title: '2.2 第三方 OIDC 认证源' },
],
},
{
value: 'access-token',
title: '3. 个人访问令牌 (AccessToken) 接口对接',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
为便于开发者或第三方工具直接调用系统
API,平台提供个人访问令牌管理功能。
</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 令牌生成与存储规范
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值
(形如{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
)
仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。
</li>
<li>
<strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256
哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('howToUse.section2.oidc')}
</h3>
<p>{t('howToUse.section2.oidcDesc')}</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>{t('howToUse.section2.oidcStep1')}</li>
<li>{t('howToUse.section2.oidcStep2')}</li>
<li>{t('howToUse.section2.oidcStep3')}</li>
</ol>
</div>
),
children: [
{ value: '2-1-login', title: t('howToUse.section2.passwordAuth') },
{ value: '2-2-oidc', title: t('howToUse.section2.oidc') },
],
},
{
value: 'access-token',
title: t('howToUse.section3.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section3.desc')}</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('howToUse.section3.generation')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section3.oneTimeDisplay')}</strong>
{t('howToUse.section3.oneTimeDisplayDesc')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
){t('howToUse.section3.oneTimeDisplayHint')}
</li>
<li>
<strong>{t('howToUse.section3.secureHash')}</strong>
{t('howToUse.section3.secureHashDesc')}
</li>
</ul>
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.2 携带 Header 进行 API 调用
</h3>
<p>
您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种
Http 请求头携带方式之一:
</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式一:Authorization Bearer 头
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{t('howToUse.section3.headerUsage')}
</h3>
<p>{t('howToUse.section3.headerUsageDesc')}</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{t('howToUse.section3.bearerHeader')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:8000
Authorization: Bearer at_628d022b7a95e26b...`}
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式二:X-Access-Token 自定义头
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{t('howToUse.section3.customHeader')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:8000
X-Access-Token: at_628d022b7a95e26b...`}
language='http'
/>
language='http'
/>
</div>
</div>
</div>
),
children: [
{ value: '3-1-generation', title: '3.1 令牌生成与存储规范' },
{ value: '3-2-usage', title: '3.2 携带 Header 进行 API 调用' },
],
},
{
value: 'config-system',
title: '4. 动态系统配置项',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以
Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。
</li>
<li>
<strong>核心系统配置项说明:</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
:平台展示名称
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
:密码登录开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
:用户自主注册开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
:普通用户创建令牌的最大数限制 (默认5)
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: '5. 异步任务与定时调度',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker
服务解耦复杂计算或高延迟IO:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron
表达式配置,定时将待执行任务推送到 Redis 队列中。
</li>
<li>
<strong>多优先级 Worker (CMD worker):</strong>基于 Asynq
驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: '6. 链路追踪与结构化日志',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>OpenTelemetry Tracing:</strong>自动传递 Tracing
上下文,所有经由 Gin 中间件、外部请求或 GORM
数据库的事务操作都将带有全局唯一的
Span,用于排查链路耗时或调用异常。
</li>
<li>
<strong>Zap 结构化日志:</strong>
将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki
等日志收集分析工具无缝对接。
</li>
</ul>
</div>
),
},
];
),
children: [
{ value: '3-1-generation', title: t('howToUse.section3.generation') },
{ value: '3-2-usage', title: t('howToUse.section3.headerUsage') },
],
},
{
value: 'config-system',
title: t('howToUse.section4.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section4.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section4.cacheAccel')}</strong>
{t('howToUse.section4.cacheAccelDesc')}
</li>
<li>
<strong>{t('howToUse.section4.coreConfigs')}</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
:{t('howToUse.section4.siteName')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
:{t('howToUse.section4.passwordLoginEnabled')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
:{t('howToUse.section4.registrationEnabled')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
:{t('howToUse.section4.maxApiKeys')}
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: t('howToUse.section5.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section5.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section5.scheduler')}</strong>
{t('howToUse.section5.schedulerDesc')}
</li>
<li>
<strong>{t('howToUse.section5.worker')}</strong>
{t('howToUse.section5.workerDesc')}
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: t('howToUse.section6.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('howToUse.section6.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('howToUse.section6.tracing')}</strong>
{t('howToUse.section6.tracingDesc')}
</li>
<li>
<strong>{t('howToUse.section6.structuredLog')}</strong>
{t('howToUse.section6.structuredLogDesc')}
</li>
</ul>
</div>
),
},
];
}
@@ -6,6 +6,7 @@ import { ArrowLeft, Calendar, Check, Copy, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { motion, useScroll, useSpring } from 'motion/react';
import { useTranslations } from 'next-intl';
import type { PolicySection } from './types';
interface LegalPageLayoutProps {
@@ -32,6 +33,7 @@ export function LegalPageLayout({
damping: 30,
restDelta: 0.001,
});
const t = useTranslations('docs');
const allSectionIds = React.useMemo(() => {
return sections.flatMap((section) => [
@@ -192,7 +194,7 @@ export function LegalPageLayout({
const lines: string[] = [];
lines.push(`# ${title}`);
lines.push('');
lines.push(`最后更新:${lastUpdated}`);
lines.push(`${t('lastUpdated')}:${lastUpdated}`);
if (description) {
const descriptionElement = contentRoot.querySelector(
'[data-docs-description]',
@@ -303,7 +305,7 @@ export function LegalPageLayout({
className='gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2'
>
<ArrowLeft className='w-4 h-4' />
返回首页
{t('backToHome')}
</Button>
</Link>
@@ -318,11 +320,11 @@ export function LegalPageLayout({
<div className='flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8'>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<FileText className='w-3.5 h-3.5' />
{sections.length} 个内容
{t('sectionCount', { count: sections.length })}
</span>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<Calendar className='w-3.5 h-3.5' />
最后更新:{lastUpdated}
{t('lastUpdated')}:{lastUpdated}
</span>
<Button
type='button'
@@ -336,7 +338,7 @@ export function LegalPageLayout({
) : (
<Copy className='w-3.5 h-3.5' />
)}
{hasCopied ? '已复制' : '复制 Markdown'}
{hasCopied ? t('copied') : t('copyMarkdown')}
</Button>
</div>
{description && (
@@ -357,7 +359,7 @@ export function LegalPageLayout({
<div className='sticky top-24'>
<div className='space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin'>
<div className='text-sm font-semibold text-foreground mb-4 pl-3'>
目录
{t('toc')}
</div>
<nav className='space-y-1'>
{sections.map((section) => (
@@ -445,7 +447,7 @@ export function LegalPageLayout({
<div className='pt-16 border-t border-border mt-20'>
<div className='bg-muted/30 rounded-2xl p-8 text-center'>
<p className='text-muted-foreground text-sm mb-4'>
对这些内容有疑问?
{t('haveQuestions')}
</p>
<div className='flex justify-center gap-4'>
<Link
@@ -453,7 +455,7 @@ export function LegalPageLayout({
target='_blank'
>
<Button variant='outline' className='h-9 text-xs'>
联系支持
{t('contactSupport')}
</Button>
</Link>
</div>
+137 -154
View File
@@ -1,160 +1,143 @@
import { type PolicySection } from './types';
/**
* ------------------------------------------------------------------
* 隐私政策 (PRIVACY POLICY)
* ------------------------------------------------------------------
*/
export const privacySections: PolicySection[] = [
{
value: 'collection-details',
title: '1. 详细信息收集说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
1.1 身份鉴权信息:
</span>
<p className='mt-1 text-muted-foreground'>
当您通过本地账号注册或绑定第三方 OIDC
认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方
OpenID 标识符。
<strong>
我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。
</strong>
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
1.2 服务与接口日志信息:
</span>
<p className='mt-1 text-muted-foreground'>
为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括
IP 地址、访问日期和时间、个人访问令牌 API
调用历史记录、User-Agent(浏览器/设备/请求工具类型)。
</p>
type TFn = (key: string) => string;
export function getPrivacySections(t: TFn): PolicySection[] {
return [
{
value: 'collection-details',
title: t('privacy.section1.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section1.desc')}</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
{t('privacy.section1.authInfo')}
</span>
<p className='mt-1 text-muted-foreground'>
{t('privacy.section1.authInfoDesc')}
<strong>{t('privacy.section1.authInfoNote')}</strong>
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
{t('privacy.section1.logInfo')}
</span>
<p className='mt-1 text-muted-foreground'>
{t('privacy.section1.logInfoDesc')}
</p>
</div>
</div>
</div>
</div>
),
},
{
value: 'storage-security',
title: '2. 数据存储与安全保护',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>存储安全:</strong>
用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。
</li>
<li>
<strong>加密技术:</strong>
敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API
数据传输链路强制使用 SSL/TLS 进行安全加密。
</li>
<li>
<strong>访问控制:</strong>我们实行严格的最小权限原则(Least
Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。
</li>
</ul>
</div>
),
},
{
value: 'usage-rules',
title: '3. 信息使用规范',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>我们收集的信息将仅用于以下目的:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1'>
<li>
<strong>身份识别:</strong>
用于确认您的注册身份,展示您的个人中心及设置页面数据。
</li>
<li>
<strong>业务功能:</strong>用于识别并处理您的个人访问令牌 API
鉴权指令。
</li>
<li>
<strong>安全风控:</strong>利用 IP
及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。
</li>
</ul>
<p>
<strong>禁止用途:</strong>我们承诺<strong>绝不</strong>
将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。
</p>
</div>
),
},
{
value: 'sharing-disclosure',
title: '4. 信息共享与对外披露',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>4.1 共享原则:</strong>
除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>事先获得您的明确授权或同意;</li>
<li>
根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
</li>
</ul>
<p>
<strong>4.2 转让与公开披露:</strong>
我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。
</p>
</div>
),
},
{
value: 'user-rights',
title: '5. 您的权利与数据管理',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
5.1 查阅与管理权:
</span>
<p className='mt-1 text-muted-foreground'>
您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的
AccessToken (API 密钥)。
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
5.2 删除与注销权:
</span>
<p className='mt-1 text-muted-foreground'>
若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。
</p>
),
},
{
value: 'storage-security',
title: t('privacy.section2.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section2.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('privacy.section2.storageSecurity')}</strong>
{t('privacy.section2.storageSecurityDesc')}
</li>
<li>
<strong>{t('privacy.section2.encryption')}</strong>
{t('privacy.section2.encryptionDesc')}
</li>
<li>
<strong>{t('privacy.section2.accessControl')}</strong>
{t('privacy.section2.accessControlDesc')}
</li>
</ul>
</div>
),
},
{
value: 'usage-rules',
title: t('privacy.section3.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section3.desc')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1'>
<li>
<strong>{t('privacy.section3.identity')}</strong>
{t('privacy.section3.identityDesc')}
</li>
<li>
<strong>{t('privacy.section3.business')}</strong>
{t('privacy.section3.businessDesc')}
</li>
<li>
<strong>{t('privacy.section3.riskControl')}</strong>
{t('privacy.section3.riskControlDesc')}
</li>
</ul>
<p>
<strong>{t('privacy.section3.prohibited')}</strong>
{t('privacy.section3.prohibitedDesc')}
</p>
</div>
),
},
{
value: 'sharing-disclosure',
title: t('privacy.section4.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('privacy.section4.sharingPrinciple')}</strong>
{t('privacy.section4.sharingPrincipleDesc')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>{t('privacy.section4.sharingException1')}</li>
<li>{t('privacy.section4.sharingException2')}</li>
</ul>
<p>
<strong>{t('privacy.section4.transfer')}</strong>
{t('privacy.section4.transferDesc')}
</p>
</div>
),
},
{
value: 'user-rights',
title: t('privacy.section5.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section5.desc')}</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
{t('privacy.section5.viewManage')}
</span>
<p className='mt-1 text-muted-foreground'>
{t('privacy.section5.viewManageDesc')}
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
{t('privacy.section5.deleteAccount')}
</span>
<p className='mt-1 text-muted-foreground'>
{t('privacy.section5.deleteAccountDesc')}
</p>
</div>
</div>
</div>
</div>
),
},
{
value: 'policy-update',
title: '6. 政策更新与通知',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
<p>
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
</p>
</div>
),
},
];
),
},
{
value: 'policy-update',
title: t('privacy.section6.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{t('privacy.section6.desc')}</p>
<p>{t('privacy.section6.notification')}</p>
</div>
),
},
];
}
+143 -151
View File
@@ -2,158 +2,150 @@ import { type PolicySection } from './types';
export const TERMS_LAST_UPDATED = '2026-06-07';
/**
* ------------------------------------------------------------------
* 服务条款 (TERMS OF SERVICE)
* ------------------------------------------------------------------
*/
export const termsSections: PolicySection[] = [
{
value: 'contract-establishment',
title: '1. 缔约申明与服务综述',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>1.1 缔约主体:</strong>
本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。
</p>
<p>
<strong>1.2 审慎阅读:</strong>
本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC
接入、API Key
令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。
</p>
<p>
<strong>1.3 协议构成:</strong>
本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。
</p>
</div>
),
},
{
value: 'service-definition',
title: '2. 服务定义与性质界定',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>责任自担:</strong>
关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。
</li>
</ul>
</div>
),
},
{
value: 'account-specifications',
title: '3. 账号注册与使用规范',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>3.1 账号体系:</strong>
用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方
OIDC 认证源进行登录关联。
</p>
<p>
<strong>3.2 密码及令牌安全责任:</strong>
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>密码安全:</strong>您应妥善保管您账户的登录密码。
</li>
<li>
<strong>API Token 安全:</strong>个人生成的 AccessToken (API Key)
代表您账户的完整调用权限。
<strong>
因您保管不善导致 Token
泄漏而造成的一切数据丢失或系统损失,均由您自行承担。
</strong>
</li>
</ul>
</div>
),
},
{
value: 'user-conduct',
title: '4. 用户行为准则(负面清单)',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。
<strong>严禁利用本平台从事以下活动(“红线条款”):</strong>
</p>
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
type TFn = (key: string) => string;
export function getTermsSections(t: TFn): PolicySection[] {
return [
{
value: 'contract-establishment',
title: t('terms.section1.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('terms.section1.subject')}</strong>
{t('terms.section1.subjectDesc')}
</p>
<p>
<strong>{t('terms.section1.readCarefully')}</strong>
{t('terms.section1.readCarefullyDesc')}
</p>
<p>
<strong>{t('terms.section1.composition')}</strong>
{t('terms.section1.compositionDesc')}
</p>
</div>
),
},
{
value: 'service-definition',
title: t('terms.section2.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>危害国家安全:</strong>
反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
</li>
<li>
<strong>非法信息服务:</strong>黑客攻击工具、DDoS
攻击服务、服务器爆破等其他非法信息服务平台;
</li>
<li>
<strong>黄赌毒关联:</strong>
制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
</li>
<li>
<strong>侵犯知识产权:</strong>
销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
</li>
<li>
<strong>其他违法信息:</strong>
涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
<strong>{t('terms.section2.liability')}</strong>
{t('terms.section2.liabilityDesc')}
</li>
</ul>
</div>
<p>
<strong>处理规则:</strong>
一旦发现您违反上述规定,平台运维主体有权不经通知
<strong>
立即永久封禁您的账号、拦截所有 AccessToken
请求,并依法向公安机关、网安部门移交相关线索。
</strong>
</p>
</div>
),
},
{
value: 'liability-limitation',
title: '5. 免责声明与不可抗力',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>5.1 基础免责:</strong>
本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。
</p>
<p>
<strong>5.2 技术服务中断:</strong>
对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>自然灾害(台风、地震、海啸、洪水等);</li>
<li>政府行为、网络安全法律法规调整或行政命令;</li>
<li>电信部门线路技术故障、机房海底光缆损毁;</li>
<li>黑客入侵、勒索病毒感染导致的数据损毁或宕机。</li>
</ul>
</div>
),
},
{
value: 'governing-law',
title: '6. 法律适用与争议解决',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>6.1 法律适用:</strong>
本协议的订立、执行、解释及争议的解决均适用
<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。
</p>
<p>
<strong>6.2 争议解决:</strong>
若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。
</p>
</div>
),
},
];
),
},
{
value: 'account-specifications',
title: t('terms.section3.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('terms.section3.accountSystem')}</strong>
{t('terms.section3.accountSystemDesc')}
</p>
<p>
<strong>{t('terms.section3.securityResponsibility')}</strong>
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{t('terms.section3.passwordSecurity')}</strong>
{t('terms.section3.passwordSecurityDesc')}
</li>
<li>
<strong>{t('terms.section3.tokenSecurity')}</strong>
{t('terms.section3.tokenSecurityDesc')}
<strong>{t('terms.section3.tokenLeakLiability')}</strong>
</li>
</ul>
</div>
),
},
{
value: 'user-conduct',
title: t('terms.section4.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{t('terms.section4.desc')}
<strong>{t('terms.section4.redLine')}</strong>
</p>
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
<li>
<strong>{t('terms.section4.nationalSecurity')}</strong>
{t('terms.section4.nationalSecurityDesc')}
</li>
<li>
<strong>{t('terms.section4.illegalInfo')}</strong>
{t('terms.section4.illegalInfoDesc')}
</li>
<li>
<strong>{t('terms.section4.harmfulContent')}</strong>
{t('terms.section4.harmfulContentDesc')}
</li>
<li>
<strong>{t('terms.section4.ipInfringement')}</strong>
{t('terms.section4.ipInfringementDesc')}
</li>
<li>
<strong>{t('terms.section4.otherIllegal')}</strong>
{t('terms.section4.otherIllegalDesc')}
</li>
</ul>
</div>
<p>
<strong>{t('terms.section4.enforcement')}</strong>
{t('terms.section4.enforcementDesc')}
<strong>{t('terms.section4.enforcementAction')}</strong>
</p>
</div>
),
},
{
value: 'liability-limitation',
title: t('terms.section5.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('terms.section5.basicDisclaimer')}</strong>
{t('terms.section5.basicDisclaimerDesc')}
</p>
<p>
<strong>{t('terms.section5.techInterruption')}</strong>
{t('terms.section5.techInterruptionDesc')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>{t('terms.section5.naturalDisaster')}</li>
<li>{t('terms.section5.govAction')}</li>
<li>{t('terms.section5.telecomFailure')}</li>
<li>{t('terms.section5.hacking')}</li>
</ul>
</div>
),
},
{
value: 'governing-law',
title: t('terms.section6.title'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{t('terms.section6.applicableLaw')}</strong>
{t('terms.section6.applicableLawDesc')}
<strong>{t('terms.section6.lawName')}</strong>
{t('terms.section6.lawScope')}
</p>
<p>
<strong>{t('terms.section6.disputeResolution')}</strong>
{t('terms.section6.disputeResolutionDesc')}
</p>
</div>
),
},
];
}
@@ -5,6 +5,7 @@ import { ImageOff } from 'lucide-react';
import { cn } from '@/lib/utils';
import { getFileUrl, type ImageQuality } from '@/lib/services/upload/utils';
import { useTranslations } from 'next-intl';
type FileImagePreviewProps = {
fileId: string | number;
@@ -25,6 +26,7 @@ export function FileImagePreview({
}: FileImagePreviewProps) {
const [failed, setFailed] = React.useState(false);
const src = getFileUrl(fileId, quality);
const t = useTranslations('common');
React.useEffect(() => {
setFailed(false);
@@ -34,7 +36,7 @@ export function FileImagePreview({
return (
<div
role='img'
aria-label={`${alt} 加载失败`}
aria-label={`${alt} ${t('loadFailed')}`}
className={cn(
'flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground',
fallbackClassName,
@@ -47,7 +49,7 @@ export function FileImagePreview({
)}
/>
{variant === 'default' && (
<span className='text-[10px] leading-none'>加载失败</span>
<span className='text-[10px] leading-none'>{t('loadFailed')}</span>
)}
</div>
);
@@ -24,6 +24,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { useTranslations } from 'next-intl';
interface ManagePageProps<T> {
title: string;
@@ -78,11 +79,12 @@ export function ManagePage<T>({
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = '暂无数据',
loadingDescription = '加载中',
emptyDescription,
loadingDescription,
getId,
headerExtra,
}: ManagePageProps<T>) {
const t = useTranslations('general.manage');
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null);
const [selectedItem, setSelectedItem] = useState<T | null>(null);
@@ -125,10 +127,10 @@ export function ManagePage<T>({
try {
setSaving(true);
await onSave(selectedItem, editData);
toast.success('保存成功');
toast.success(t('saveSuccess'));
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误',
toast.error(t('saveFailed'), {
description: error instanceof Error ? error.message : t('unknownError'),
});
} finally {
setSaving(false);
@@ -144,7 +146,7 @@ export function ManagePage<T>({
if (!deletingItem || !onDelete) return;
try {
await onDelete(deletingItem);
toast.success('删除成功');
toast.success(t('deleteSuccess'));
setDeletingItem(null);
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
@@ -152,8 +154,8 @@ export function ManagePage<T>({
setEditData({});
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误',
toast.error(t('deleteFailed'), {
description: error instanceof Error ? error.message : t('unknownError'),
});
}
};
@@ -164,7 +166,7 @@ export function ManagePage<T>({
return (
<LoadingStateWithBorder
icon={ListRestart}
description={loadingDescription}
description={loadingDescription ?? t('loading')}
/>
);
}
@@ -183,7 +185,10 @@ export function ManagePage<T>({
if (!data || data.length === 0) {
return (
<EmptyStateWithBorder icon={emptyIcon} description={emptyDescription} />
<EmptyStateWithBorder
icon={emptyIcon}
description={emptyDescription ?? t('noData')}
/>
);
}
@@ -216,7 +221,7 @@ export function ManagePage<T>({
<div className='space-y-6'>
<div>
<div className='mb-4'>
<div className='font-semibold'>配置列表</div>
<div className='font-semibold'>{t('configList')}</div>
</div>
{renderContent()}
</div>
@@ -239,18 +244,18 @@ export function ManagePage<T>({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogTitle>{t('confirmDelete')}</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除该配置。
{t('confirmDeleteDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className='bg-red-600 hover:bg-red-700'
>
确认删除
{t('confirmDeleteAction')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -271,18 +276,24 @@ interface ManageDetailPanelProps {
/** 详情面板 */
export function ManageDetailPanel({
title = '配置信息',
title,
isEmpty,
emptyDescription = '请选择配置查看详情',
emptyDescription,
onSave,
saving = false,
children,
}: ManageDetailPanelProps) {
const t = useTranslations('general.manage');
const displayTitle = title ?? t('configInfo');
const displayEmptyDescription = emptyDescription ?? t('selectConfigToView');
if (isEmpty) {
return (
<div className='space-y-4'>
<div className='font-semibold mb-4'>{title}</div>
<EmptyStateWithBorder icon={Layers} description={emptyDescription} />
<div className='font-semibold mb-4'>{displayTitle}</div>
<EmptyStateWithBorder
icon={Layers}
description={displayEmptyDescription}
/>
</div>
);
}
@@ -291,7 +302,7 @@ export function ManageDetailPanel({
<div className='space-y-4 sticky top-6'>
<div>
<div className='flex items-center justify-between mb-4'>
<div className='font-semibold'>{title}</div>
<div className='font-semibold'>{displayTitle}</div>
{onSave && (
<Button
onClick={onSave}
@@ -301,10 +312,10 @@ export function ManageDetailPanel({
>
{saving ? (
<>
<Spinner /> 更新中
<Spinner /> {t('updating')}
</>
) : (
'更新'
t('update')
)}
</Button>
)}
@@ -341,6 +352,7 @@ export function ManageTable<T>({
onDelete?: (item: T) => void;
getId: (item: T) => string | number;
}) {
const t = useTranslations('general.manage');
return (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<ScrollArea className='w-full'>
@@ -358,7 +370,7 @@ export function ManageTable<T>({
))}
{onDelete && (
<TableHead className='whitespace-nowrap text-center w-[120px]'>
操作
{t('actions')}
</TableHead>
)}
</TableRow>
@@ -16,6 +16,7 @@ import {
} from '@/components/ui/input-otp';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { useTranslations } from 'next-intl';
interface PasswordDialogProps {
isOpen: boolean;
@@ -36,10 +37,13 @@ export function PasswordDialog({
onOpenChange,
onConfirm,
loading = false,
title = '请输入密码',
description = '为了您的账户安全,请输入6位认证密码进行验证',
title,
description,
}: PasswordDialogProps) {
const [password, setPassword] = useState('');
const t = useTranslations('general.passwordDialog');
const displayTitle = title ?? t('enterPassword');
const displayDescription = description ?? t('enterPasswordDesc');
const handleConfirm = () => {
if (password.length === 6) {
@@ -58,8 +62,8 @@ export function PasswordDialog({
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className='w-xs sm:w-md'>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
<DialogTitle>{displayTitle}</DialogTitle>
<DialogDescription>{displayDescription}</DialogDescription>
</DialogHeader>
<div className='py-2 flex justify-center'>
@@ -92,7 +96,7 @@ export function PasswordDialog({
disabled={loading}
className='h-8 text-xs'
>
取消
{t('cancel')}
</Button>
<Button
type='button'
@@ -103,10 +107,10 @@ export function PasswordDialog({
{loading ? (
<>
<Spinner className='mr-2 h-4 w-4' />
验证中...
{t('verifying')}
</>
) : (
'确认'
t('confirm')
)}
</Button>
</div>
+28 -28
View File
@@ -17,15 +17,17 @@ import {
User,
} from 'lucide-react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
export function HomeMain() {
const { user } = useUser();
const [isBannerVisible, setIsBannerVisible] = React.useState(true);
const t = useTranslations('home');
const quickLinks = [
{
title: '个人资料',
description: '管理您的个人账户信息及个性化配置',
title: t('quickLinks.profile.title'),
description: t('quickLinks.profile.description'),
icon: User,
url: '/settings/profile',
color: 'text-blue-500',
@@ -33,8 +35,8 @@ export function HomeMain() {
borderColor: 'hover:border-blue-500/30',
},
{
title: '开发接口文档',
description: '查看开放平台的 RESTful 接口规格说明',
title: t('quickLinks.apiDocs.title'),
description: t('quickLinks.apiDocs.description'),
icon: FileText,
url: '/docs/api',
color: 'text-emerald-500',
@@ -43,8 +45,8 @@ export function HomeMain() {
external: true,
},
{
title: '使用文档',
description: '学习如何集成 API 及日常操作帮助指南',
title: t('quickLinks.userDocs.title'),
description: t('quickLinks.userDocs.description'),
icon: HelpCircle,
url: '/docs/how-to-use',
color: 'text-purple-500',
@@ -56,8 +58,8 @@ export function HomeMain() {
const adminLinks = [
{
title: '系统设置',
description: '管理登录注册开关与认证源安全策略',
title: t('adminLinks.settings.title'),
description: t('adminLinks.settings.description'),
icon: Shield,
url: '/admin/settings',
color: 'text-amber-500',
@@ -65,8 +67,8 @@ export function HomeMain() {
borderColor: 'hover:border-amber-500/30',
},
{
title: '全局系统配置',
description: '动态管理平台运行时核心配置参数',
title: t('adminLinks.systemConfig.title'),
description: t('adminLinks.systemConfig.description'),
icon: ShieldCheck,
url: '/admin/system',
color: 'text-indigo-500',
@@ -74,8 +76,8 @@ export function HomeMain() {
borderColor: 'hover:border-indigo-500/30',
},
{
title: '用户权限管理',
description: '集中查询并管理系统注册用户的启用状态',
title: t('adminLinks.userManagement.title'),
description: t('adminLinks.userManagement.description'),
icon: User,
url: '/admin/users',
color: 'text-rose-500',
@@ -83,8 +85,8 @@ export function HomeMain() {
borderColor: 'hover:border-rose-500/30',
},
{
title: '后台任务调度',
description: '分发与观测系统异步定时任务的执行情况',
title: t('adminLinks.tasks.title'),
description: t('adminLinks.tasks.description'),
icon: Layers,
url: '/admin/tasks',
color: 'text-teal-500',
@@ -92,8 +94,8 @@ export function HomeMain() {
borderColor: 'hover:border-teal-500/30',
},
{
title: '系统日志',
description: '查看异步任务执行日志与系统运行状态详情',
title: t('adminLinks.logs.title'),
description: t('adminLinks.logs.description'),
icon: Terminal,
url: '/admin/logs',
color: 'text-cyan-500',
@@ -104,7 +106,6 @@ export function HomeMain() {
return (
<div className='py-6 space-y-8 max-w-6xl mx-auto'>
{/* 积分收益提示 banner */}
<AnimatePresence>
{isBannerVisible && (
<motion.div
@@ -120,8 +121,7 @@ export function HomeMain() {
Modern Platform
</h3>
<p className='text-xs text-muted-foreground leading-relaxed'>
通用的、现代化的后台管理系统脚手架,
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
{t('banner.description')}
</p>
<div className='flex gap-2.5'>
<Button
@@ -129,7 +129,7 @@ export function HomeMain() {
className='bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm h-7'
asChild
>
<Link href='/home'>开始使用</Link>
<Link href='/home'>{t('banner.getStarted')}</Link>
</Button>
<Button
size='sm'
@@ -137,7 +137,7 @@ export function HomeMain() {
className='text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7'
onClick={() => setIsBannerVisible(false)}
>
隐藏提示
{t('banner.hideHint')}
</Button>
</div>
</div>
@@ -146,9 +146,10 @@ export function HomeMain() {
)}
</AnimatePresence>
{/* 快捷导航 */}
<div className='space-y-4'>
<h2 className='text-lg font-semibold tracking-tight'>常用功能导航</h2>
<h2 className='text-lg font-semibold tracking-tight'>
{t('navigation.title')}
</h2>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{quickLinks.map((link, idx) => (
<motion.div
@@ -190,7 +191,7 @@ export function HomeMain() {
target='_blank'
rel='noopener noreferrer'
>
立即跳转 <ArrowRight className='size-3 ml-1' />
{t('jumpNow')} <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
) : (
@@ -200,7 +201,7 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
立即进入 <ArrowRight className='size-3 ml-1' />
{t('enterNow')} <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
)}
@@ -211,12 +212,11 @@ export function HomeMain() {
</div>
</div>
{/* 管理面板 (仅管理员可见) */}
{user?.is_admin && (
<div className='space-y-4 pt-2'>
<h2 className='text-lg font-semibold tracking-tight text-rose-500 flex items-center gap-1.5'>
<Shield className='size-5' />
后台管理控制台
{t('adminConsole')}
</h2>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
{adminLinks.map((link, idx) => (
@@ -251,7 +251,7 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
立即进入 <ArrowRight className='size-3 ml-1' />
{t('enterNow')} <ArrowRight className='size-3 ml-1' />
</Link>
</Button>
</div>
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -39,6 +40,7 @@ export function AuthSourceModal({
onClose: () => void;
onChanged: () => Promise<void>;
}) {
const t = useTranslations('settings.authSourceModal');
const [form, setForm] = useState<AuthSourceRequest>(emptyForm);
const [saving, setSaving] = useState(false);
@@ -74,10 +76,12 @@ export function AuthSourceModal({
await services.adminAuthSource.createAuthSource(form);
}
await onChanged();
toast.success('认证源已保存');
toast.success(t('authSourceSaved'));
onClose();
} catch (error) {
toast.error(error instanceof Error ? error.message : '保存认证源失败');
toast.error(
error instanceof Error ? error.message : t('saveAuthSourceFailed'),
);
} finally {
setSaving(false);
}
@@ -87,17 +91,17 @@ export function AuthSourceModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className='max-w-xl'>
<DialogHeader>
<DialogTitle>{source ? '编辑认证源' : '新增认证源'}</DialogTitle>
<DialogTitle>
{source ? t('editAuthSource') : t('addAuthSource')}
</DialogTitle>
<DialogDescription>
{source
? '修改系统自定义的 OIDC 认证源参数。'
: '配置新的自定义 OIDC 认证源。'}
{source ? t('editAuthSourceDesc') : t('addAuthSourceDesc')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 md:grid-cols-2 pt-2'>
<div className='space-y-2'>
<Label>标识符 (英文名称)</Label>
<Label>{t('identifier')}</Label>
<Input
value={form.name}
disabled={!!source}
@@ -108,7 +112,7 @@ export function AuthSourceModal({
/>
</div>
<div className='space-y-2'>
<Label>展示名称</Label>
<Label>{t('displayName')}</Label>
<Input
value={form.display_name}
onChange={(e) =>
@@ -133,11 +137,11 @@ export function AuthSourceModal({
onChange={(e) =>
setForm((prev) => ({ ...prev, client_secret: e.target.value }))
}
placeholder={source ? '留空则保留原值' : ''}
placeholder={source ? t('keepOriginalIfEmpty') : ''}
/>
</div>
<div className='space-y-2 md:col-span-2'>
<Label>Discovery URL (OIDC 发行方 URL)</Label>
<Label>{t('discoveryURL')}</Label>
<Input
value={form.openid_discovery_url}
onChange={(e) =>
@@ -159,7 +163,7 @@ export function AuthSourceModal({
/>
</div>
<div className='space-y-2'>
<Label>图标 URL (可选)</Label>
<Label>{t('iconURL')}</Label>
<Input
value={form.icon_url}
onChange={(e) =>
@@ -170,9 +174,9 @@ export function AuthSourceModal({
</div>
<div className='flex items-center justify-between rounded-xl border border-dashed p-3 md:col-span-2 bg-muted/10'>
<div>
<div className='font-medium text-sm'>启用认证源</div>
<div className='font-medium text-sm'>{t('enableAuthSource')}</div>
<div className='text-xs text-muted-foreground'>
启用后会立即显示在登录页和账号绑定中。
{t('enableAuthSourceDesc')}
</div>
</div>
<Switch
@@ -184,7 +188,7 @@ export function AuthSourceModal({
</div>
<div className='md:col-span-2 flex justify-end gap-2 pt-2 border-t mt-2'>
<Button variant='outline' type='button' onClick={onClose}>
取消
{t('cancel')}
</Button>
<Button
type='button'
@@ -192,7 +196,7 @@ export function AuthSourceModal({
disabled={saving}
variant='secondary'
>
{saving ? '保存中...' : '保存'}
{saving ? t('saving') : t('save')}
</Button>
</div>
</div>
@@ -3,6 +3,7 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { ExternalLink, RefreshCw, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import services from '@/lib/services';
import {
@@ -39,6 +40,7 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
}
export function InfoTab() {
const t = useTranslations('settings.info');
const updateQuery = useQuery({
queryKey: ['admin', 'update'],
queryFn: () => services.adminStatus.getUpdateStatus(),
@@ -49,10 +51,10 @@ export function InfoTab() {
const applyUpdateMutation = useMutation({
mutationFn: () => services.adminStatus.applyUpdate(),
onSuccess: () => {
toast.success('升级包已校验完成,服务正在重启');
toast.success(t('upgradeVerified'));
},
onError: (error: Error) => {
toast.error(error.message || '应用升级失败');
toast.error(error.message || t('applyUpgradeFailed'));
},
});
@@ -69,17 +71,17 @@ export function InfoTab() {
</div>
<div>
<CardTitle className='text-base font-semibold'>
应用更新
{t('appUpdate')}
</CardTitle>
<CardDescription className='text-xs'>
检查上游 GitHub Actions Release 并升级当前服务
{t('appUpdateDesc')}
</CardDescription>
</div>
</div>
{update?.update_available ? (
<Badge>发现新版本</Badge>
<Badge>{t('newVersionAvailable')}</Badge>
) : update ? (
<Badge variant='secondary'>已是最新</Badge>
<Badge variant='secondary'>{t('alreadyLatest')}</Badge>
) : null}
</div>
</CardHeader>
@@ -91,7 +93,7 @@ export function InfoTab() {
) : updateQuery.isError ? (
<div className='flex min-h-32 flex-col items-center justify-center gap-3 text-center'>
<p className='text-xs text-muted-foreground'>
{updateQuery.error.message || '无法获取上游版本信息'}
{updateQuery.error.message || t('cannotGetVersion')}
</p>
<Button
type='button'
@@ -100,25 +102,34 @@ export function InfoTab() {
onClick={() => updateQuery.refetch()}
>
<RefreshCw data-icon='inline-start' />
重新检查
{t('recheck')}
</Button>
</div>
) : update ? (
<>
<div>
<InfoRow label='当前版本' value={update.current_version} />
<InfoRow label='最新版本' value={update.latest_version} />
<InfoRow
label={t('currentVersion')}
value={update.current_version}
/>
<InfoRow
label={t('latestVersion')}
value={update.latest_version}
/>
{update.build_time && (
<InfoRow label='构建时间' value={update.build_time} />
<InfoRow label={t('buildTime')} value={update.build_time} />
)}
<InfoRow label='运行平台' value={update.platform} />
<InfoRow label='上游仓库' value={update.upstream_repository} />
<InfoRow label='Release 资产' value={update.asset_name} />
<InfoRow label={t('platform')} value={update.platform} />
<InfoRow
label={t('upstreamRepo')}
value={update.upstream_repository}
/>
<InfoRow label={t('releaseAsset')} value={update.asset_name} />
</div>
{update.release_notes && (
<div className='flex flex-col gap-2 rounded-md border border-dashed p-3'>
<p className='text-xs font-medium'>更新说明</p>
<p className='text-xs font-medium'>{t('releaseNotes')}</p>
<p className='max-h-40 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground'>
{update.release_notes}
</p>
@@ -138,7 +149,7 @@ export function InfoTab() {
) : (
<RefreshCw data-icon='inline-start' />
)}
检查更新
{t('checkUpdate')}
</Button>
{update.release_url && (
<Button asChild variant='outline' size='sm'>
@@ -148,7 +159,7 @@ export function InfoTab() {
rel='noreferrer'
>
<ExternalLink data-icon='inline-start' />
查看 Release
{t('viewRelease')}
</a>
</Button>
)}
@@ -164,25 +175,24 @@ export function InfoTab() {
{applyUpdateMutation.isPending && (
<Spinner data-icon='inline-start' />
)}
立即升级
{t('upgradeNow')}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
升级到 {update.latest_version}?
{t('upgradeTo', { version: update.latest_version })}
</AlertDialogTitle>
<AlertDialogDescription>
服务将下载并校验 {update.asset_name}
,随后替换当前二进制并重启。请确保安装目录可写,且服务允许原地重启。
{t('upgradeDesc', { asset: update.asset_name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => applyUpdateMutation.mutate()}
>
确认升级
{t('confirmUpgrade')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -192,10 +202,10 @@ export function InfoTab() {
{!update.can_upgrade && (
<p className='text-xs text-muted-foreground'>
{update.current_version === 'dev'
? '开发构建没有可比较的 Release 版本,不能执行自动升级。'
? t('devBuildNoUpgrade')
: update.update_available
? '当前平台暂不支持自动替换二进制,请从 Release 页面手动升级。'
: '当前版本无需升级。'}
? t('platformNotSupported')
: t('noUpgradeNeeded')}
</p>
)}
</>
@@ -27,6 +27,7 @@ import services from '@/lib/services';
import type { SystemConfig } from '@/lib/services/admin';
import { TemplatesManager } from './templates';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
interface OperationTabProps {
configs: Record<string, SystemConfig>;
@@ -38,6 +39,7 @@ export function OperationTab({
systemConfigsQuery,
}: OperationTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.operation');
const uploadTypesQuery = useQuery({
queryKey: ['admin', 'upload-types'],
@@ -63,10 +65,10 @@ export function OperationTab({
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('文件访问白名单已更新');
toast.success(t('whitelistUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新白名单失败');
toast.error(error.message || t('updateWhitelistFailed'));
},
});
@@ -120,10 +122,10 @@ export function OperationTab({
</div>
<div>
<CardTitle className='text-base font-semibold'>
文件访问权限控制
{t('fileAccessControl')}
</CardTitle>
<CardDescription className='text-xs'>
配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。
{t('fileAccessControlDesc')}
</CardDescription>
</div>
</div>
@@ -132,7 +134,7 @@ export function OperationTab({
<div className='flex flex-col gap-4'>
<div className='flex items-center gap-3'>
<span className='text-sm font-medium text-muted-foreground'>
添加免鉴权类型:
{t('addAuthFreeType')}
</span>
<Select
value=''
@@ -144,7 +146,7 @@ export function OperationTab({
}
>
<SelectTrigger className='w-[200px]' size='sm'>
<SelectValue placeholder='选择业务类型...' />
<SelectValue placeholder={t('selectBusinessType')} />
</SelectTrigger>
<SelectContent>
{availableTypes
@@ -158,7 +160,7 @@ export function OperationTab({
(t) => !currentWhitelist.includes(t.value),
).length === 0 && (
<div className='text-xs text-muted-foreground p-2 text-center'>
所有类型已添加
{t('allTypesAdded')}
</div>
)}
</SelectContent>
@@ -170,7 +172,7 @@ export function OperationTab({
<div className='flex items-center gap-2'>
<ShieldAlert className='size-4 text-primary' />
<span className='font-medium text-sm text-foreground'>
当前免鉴权列表
{t('currentAuthFreeList')}
</span>
</div>
@@ -200,7 +202,7 @@ export function OperationTab({
</div>
) : (
<p className='text-xs text-muted-foreground'>
白名单已空,所有类型文件的访问都将需要登录。
{t('whitelistEmpty')}
</p>
)}
</div>
@@ -30,6 +30,7 @@ import { Switch } from '@/components/ui/switch';
import services from '@/lib/services';
import type { SystemConfig } from '@/lib/services/admin';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
interface MenuItem {
path: string;
@@ -147,6 +148,7 @@ interface OtherTabProps {
export function OtherTab({ configs }: OtherTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.other');
const menuDisplayConfig = useMemo(() => {
const raw = configs['menu_display_config']?.value;
@@ -183,10 +185,10 @@ export function OtherTab({ configs }: OtherTabProps) {
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('目录显示配置已更新');
toast.success(t('menuDisplayUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新配置失败');
toast.error(error.message || t('updateConfigFailed'));
},
});
@@ -203,10 +205,10 @@ export function OtherTab({ configs }: OtherTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
目录显示管理
{t('menuDisplayManagement')}
</CardTitle>
<CardDescription className='text-xs'>
配置系统左侧菜单的显示与隐藏状态,适用于所有登录用户。
{t('menuDisplayManagementDesc')}
</CardDescription>
</div>
</div>
@@ -241,7 +243,7 @@ export function OtherTab({ configs }: OtherTabProps) {
</span>
{isReadOnly && (
<span className='text-[9px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground border shrink-0'>
不可隐藏
{t('notHideable')}
</span>
)}
</div>
@@ -270,8 +272,10 @@ export function OtherTab({ configs }: OtherTabProps) {
<div className='p-3.5 rounded-lg border border-dashed border-primary/20 bg-primary/5 flex items-start gap-2.5'>
<Info className='size-4 text-primary shrink-0 mt-0.5' />
<div className='text-xs text-muted-foreground leading-relaxed'>
<span className='font-semibold text-foreground'>安全提示:</span>
为了防止管理员在关闭“系统设置”后导致无法重新访问此配置页,系统限制了“系统设置”的关闭权限。其它所有菜单均可自由开关,隐藏后对应的分组标题在为空时也会自动隐藏。
<span className='font-semibold text-foreground'>
{t('securityTipTitle')}
</span>
{t('securityTipDesc')}
</div>
</div>
</CardContent>
+96 -87
View File
@@ -64,10 +64,12 @@ import {
ImageCropReset,
} from '@/components/ui/image-crop';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
export function ProfileMain() {
const { user, loading, refetch } = useUser();
const controls = useAnimation();
const t = useTranslations('settings.profile');
const isAnimatingRef = React.useRef(false);
const queryClient = useQueryClient();
@@ -109,25 +111,25 @@ export function ProfileMain() {
const changePasswordMutation = useMutation({
mutationFn: (req: ChangePasswordRequest) => AuthService.changePassword(req),
onSuccess: () => {
toast.success('密码修改成功');
toast.success(t('passwordChangeSuccess'));
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
void refetch();
},
onError: (error: Error) => {
toast.error(error.message || '修改密码失败,请重试');
toast.error(error.message || t('passwordChangeFailed'));
},
});
const handlePasswordChange = (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
toast.error('两次输入的新密码不一致');
toast.error(t('passwordMismatch'));
return;
}
if (newPassword.length < 8) {
toast.error('新密码长度不能少于 8 位');
toast.error(t('passwordTooShort'));
return;
}
changePasswordMutation.mutate({
@@ -139,12 +141,12 @@ export function ProfileMain() {
const updateProfileMutation = useMutation({
mutationFn: (req: UpdateProfileRequest) => AuthService.updateProfile(req),
onSuccess: () => {
toast.success('个人信息修改成功');
toast.success(t('profileUpdateSuccess'));
setIsEditDialogOpen(false);
void refetch();
},
onError: (error: Error) => {
toast.error(error.message || '修改个人信息失败,请重试');
toast.error(error.message || t('profileUpdateFailed'));
},
});
@@ -185,7 +187,7 @@ export function ProfileMain() {
window.location.href = authorize_url;
},
onError: (error: Error) => {
toast.error(error.message || '绑定认证源失败');
toast.error(error.message || t('bindSourceFailed'));
},
});
@@ -221,22 +223,22 @@ export function ProfileMain() {
setAvatarUrl(`/f/${res.id}`);
setIsCropDialogOpen(false);
setCropFile(null);
toast.success('头像上传成功,点击保存以生效');
toast.success(t('avatarUploadSuccess'));
} catch (err) {
toast.error((err as Error).message || '上传头像失败');
toast.error((err as Error).message || t('avatarUploadFailed'));
}
};
const getGenderLabel = (g?: string) => {
switch (g) {
case 'male':
return '男';
return t('genderMale');
case 'female':
return '女';
return t('genderFemale');
case 'secret':
return '保密';
return t('genderSecret');
default:
return g || '保密';
return g || t('genderSecret');
}
};
@@ -244,7 +246,7 @@ export function ProfileMain() {
return (
<div className='py-6 space-y-4 max-w-4xl mx-auto'>
<div className='border-b border-border pb-4'>
<h1 className='text-2xl font-semibold'>个人资料</h1>
<h1 className='text-2xl font-semibold'>{t('title')}</h1>
</div>
</div>
);
@@ -253,7 +255,7 @@ export function ProfileMain() {
if (!user) {
return (
<div className='py-6 space-y-6 max-w-4xl mx-auto'>
<div className='text-sm text-muted-foreground'>未找到用户信息</div>
<div className='text-sm text-muted-foreground'>{t('userNotFound')}</div>
</div>
);
}
@@ -266,14 +268,14 @@ export function ProfileMain() {
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href='/settings' className='text-base text-primary'>
设置
{t('breadcrumbSettings')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className='text-base font-semibold'>
个人资料
{t('breadcrumbProfile')}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
@@ -284,9 +286,11 @@ export function ProfileMain() {
<div className='space-y-6 bg-card border border-dashed rounded-lg p-6'>
<div className='border-b pb-4 flex justify-between items-center'>
<div>
<h2 className='text-lg font-semibold tracking-tight'>基本资料</h2>
<h2 className='text-lg font-semibold tracking-tight'>
{t('basicInfo')}
</h2>
<p className='text-xs text-muted-foreground'>
您的个人账户基本信息
{t('basicInfoDesc')}
</p>
</div>
<Button
@@ -296,7 +300,7 @@ export function ProfileMain() {
onClick={() => setIsEditDialogOpen(true)}
>
<Edit className='size-3.5 mr-1.5' />
编辑资料
{t('editProfile')}
</Button>
</div>
@@ -323,7 +327,7 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<UserIcon className='size-3 text-muted-foreground/70' />
账户
{t('account')}
</div>
<div className='text-sm font-semibold'>@{user.username}</div>
</div>
@@ -331,17 +335,17 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<UserIcon className='size-3 text-muted-foreground/70' />
昵称
{t('nickname')}
</div>
<div className='text-sm font-semibold'>
{user.nickname || '未设置'}
{user.nickname || t('notSet')}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Info className='size-3 text-muted-foreground/70' />
用户ID (UID)
{t('uid')}
</div>
<div className='text-sm font-mono font-semibold'>{user.id}</div>
</div>
@@ -349,27 +353,27 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Mail className='size-3 text-muted-foreground/70' />
邮箱
{t('email')}
</div>
<div className='text-sm font-semibold truncate max-w-[220px]'>
{user.email || '未绑定'}
{user.email || t('notBound')}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Phone className='size-3 text-muted-foreground/70' />
手机号码
{t('phone')}
</div>
<div className='text-sm font-semibold'>
{user.phone || '未设置'}
{user.phone || t('notSet')}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<UserIcon className='size-3 text-muted-foreground/70' />
性别
{t('gender')}
</div>
<div className='text-sm font-semibold'>
{getGenderLabel(user.gender)}
@@ -379,7 +383,7 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Globe className='size-3 text-muted-foreground/70' />
个人网站
{t('website')}
</div>
<div className='text-sm font-semibold truncate max-w-[220px]'>
{user.website ? (
@@ -396,7 +400,7 @@ export function ProfileMain() {
{user.website}
</a>
) : (
'未设置'
t('notSet')
)}
</div>
</div>
@@ -404,25 +408,26 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<MapPin className='size-3 text-muted-foreground/70' />
所在地
{t('location')}
</div>
<div className='text-sm font-semibold'>
{user.location || '未设置'}
{user.location || t('notSet')}
</div>
</div>
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<Shield className='size-3 text-muted-foreground/70' />
管理员身份
{t('isAdmin')}
</div>
<div className='text-sm font-semibold flex items-center gap-1'>
{user.is_admin ? (
<span className='text-rose-600 flex items-center gap-1'>
<Shield className='size-3.5' />是
<Shield className='size-3.5' />
{t('yes')}
</span>
) : (
<span>否</span>
<span>{t('no')}</span>
)}
</div>
</div>
@@ -433,10 +438,10 @@ export function ProfileMain() {
<div className='space-y-1'>
<div className='text-xs text-muted-foreground flex items-center gap-1.5'>
<BookOpen className='size-3 text-muted-foreground/70' />
个人简介
{t('bio')}
</div>
<div className='text-sm text-foreground/80 leading-relaxed max-w-2xl bg-muted/20 border border-dashed rounded-lg p-3'>
{user.bio || '这个人很懒,什么都没有留下。'}
{user.bio || t('bioDefault')}
</div>
</div>
</div>
@@ -454,10 +459,10 @@ export function ProfileMain() {
</div>
<div>
<h2 className='text-base font-semibold tracking-tight'>
修改密码
{t('changePassword')}
</h2>
<p className='text-[11px] text-muted-foreground'>
更改您的账号密码以确保安全。密码长度不能少于 8 位。
{t('changePasswordDesc')}
</p>
</div>
</div>
@@ -466,9 +471,9 @@ export function ProfileMain() {
<div className='mt-4 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-2.5 text-xs text-amber-500 flex items-start gap-2.5'>
<Info className='size-4 shrink-0 mt-0.5' />
<div>
<p className='font-semibold'>密码风险提示</p>
<p className='font-semibold'>{t('passwordRiskWarning')}</p>
<p className='mt-0.5 text-amber-500/80 leading-relaxed font-normal'>
为了账号安全,您必须修改初始密码。
{t('mustChangePassword')}
</p>
</div>
</div>
@@ -477,11 +482,11 @@ export function ProfileMain() {
<form onSubmit={handlePasswordChange} className='space-y-3 pt-4'>
<div className='space-y-1.5'>
<label className='text-xs font-medium text-muted-foreground'>
当前密码
{t('currentPassword')}
</label>
<Input
type='password'
placeholder='请输入当前密码'
placeholder={t('currentPasswordPlaceholder')}
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className='h-8 text-xs rounded-lg'
@@ -490,11 +495,11 @@ export function ProfileMain() {
</div>
<div className='space-y-1.5'>
<label className='text-xs font-medium text-muted-foreground'>
新密码
{t('newPassword')}
</label>
<Input
type='password'
placeholder='新密码(至少 8 位)'
placeholder={t('newPasswordPlaceholder')}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className='h-8 text-xs rounded-lg'
@@ -503,11 +508,11 @@ export function ProfileMain() {
</div>
<div className='space-y-1.5'>
<label className='text-xs font-medium text-muted-foreground'>
确认新密码
{t('confirmNewPassword')}
</label>
<Input
type='password'
placeholder='确认新密码'
placeholder={t('confirmNewPasswordPlaceholder')}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className='h-8 text-xs rounded-lg'
@@ -522,7 +527,9 @@ export function ProfileMain() {
className='w-full sm:w-auto h-8 text-xs'
disabled={changePasswordMutation.isPending}
>
{changePasswordMutation.isPending ? '提交中...' : '确认修改'}
{changePasswordMutation.isPending
? t('submitting')
: t('confirmChange')}
</Button>
</div>
</form>
@@ -538,10 +545,10 @@ export function ProfileMain() {
</div>
<div>
<h2 className='text-base font-semibold tracking-tight'>
第三方账号绑定
{t('thirdPartyBinding')}
</h2>
<p className='text-[11px] text-muted-foreground'>
管理并关联您的第三方授权账户,便于快捷登录与验证
{t('thirdPartyBindingDesc')}
</p>
</div>
</div>
@@ -549,7 +556,7 @@ export function ProfileMain() {
{/* 已绑定账号列表 */}
<div className='space-y-2 pt-4'>
<h3 className='text-[11px] font-semibold text-muted-foreground uppercase tracking-wider'>
已绑定账号
{t('boundAccounts')}
</h3>
{externalAccountBindingsQuery.isPending ? (
<div className='flex items-center justify-center py-4'>
@@ -569,7 +576,7 @@ export function ProfileMain() {
<span className='text-[10px] text-muted-foreground font-mono block truncate max-w-[150px]'>
{binding.external_username ||
binding.email ||
'未提供账号标识'}
t('noAccountIdentifier')}
</span>
</div>
<Button
@@ -584,11 +591,11 @@ export function ProfileMain() {
await queryClient.invalidateQueries({
queryKey: ['auth', 'external-accounts'],
});
toast.success('绑定已移除');
toast.success(t('bindingRemoved'));
}}
>
<Unlink className='size-3 mr-1' />
解绑
{t('unbind')}
</Button>
</div>
))}
@@ -596,7 +603,7 @@ export function ProfileMain() {
) : (
<div className='rounded-xl border border-dashed border-border/50 px-4 py-6 text-center text-[11px] text-muted-foreground bg-muted/5 flex flex-col items-center justify-center'>
<Link2 className='size-5 text-muted-foreground/30 mb-1' />
暂无绑定的第三方账号
{t('noBoundAccounts')}
</div>
)}
</div>
@@ -606,7 +613,7 @@ export function ProfileMain() {
{/* 绑定新账号列表 */}
<div className='space-y-2'>
<h3 className='text-[11px] font-semibold text-muted-foreground uppercase tracking-wider'>
绑定新账号
{t('bindNewAccount')}
</h3>
{publicAuthSourcesQuery.isPending ? (
<div className='flex items-center justify-center py-4'>
@@ -626,7 +633,11 @@ export function ProfileMain() {
>
<div className='flex items-center gap-1.5'>
<Link2 className='size-3 text-muted-foreground group-hover:text-primary' />
<span>绑定 {source.display_name || source.name}</span>
<span>
{t('bindSource', {
name: source.display_name || source.name,
})}
</span>
</div>
<ArrowRight className='size-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-primary' />
</Button>
@@ -634,7 +645,7 @@ export function ProfileMain() {
</div>
) : (
<div className='text-[11px] text-muted-foreground text-center py-2'>
暂无可用第三方认证源
{t('noAvailableSources')}
</div>
)}
</div>
@@ -646,10 +657,8 @@ export function ProfileMain() {
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>编辑个人资料</DialogTitle>
<DialogDescription>
修改您的基本信息,保存后立即生效。
</DialogDescription>
<DialogTitle>{t('editProfileTitle')}</DialogTitle>
<DialogDescription>{t('editProfileDesc')}</DialogDescription>
</DialogHeader>
<form onSubmit={handleProfileSubmit} className='space-y-4'>
@@ -670,7 +679,7 @@ export function ProfileMain() {
</div>
</div>
<span className='text-[10px] text-muted-foreground'>
点击上传/修改头像
{t('uploadAvatar')}
</span>
<input
type='file'
@@ -684,21 +693,21 @@ export function ProfileMain() {
<div className='grid grid-cols-2 gap-3'>
<div className='space-y-1'>
<Label htmlFor='nickname' className='text-xs'>
昵称
{t('nickname')}
</Label>
<Input
id='nickname'
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className='h-8 text-xs rounded-lg'
placeholder='请输入您的昵称'
placeholder={t('nicknamePlaceholder')}
required
/>
</div>
<div className='space-y-1'>
<Label htmlFor='email' className='text-xs'>
邮箱
{t('email')}
</Label>
<Input
id='email'
@@ -706,26 +715,26 @@ export function ProfileMain() {
value={email}
onChange={(e) => setEmail(e.target.value)}
className='h-8 text-xs rounded-lg'
placeholder='请输入您的邮箱'
placeholder={t('emailPlaceholder')}
/>
</div>
<div className='space-y-1'>
<Label htmlFor='phone' className='text-xs'>
手机号码
{t('phone')}
</Label>
<Input
id='phone'
value={phone}
onChange={(e) => setPhone(e.target.value)}
className='h-8 text-xs rounded-lg'
placeholder='请输入手机号'
placeholder={t('phonePlaceholder')}
/>
</div>
<div className='space-y-1'>
<Label htmlFor='gender' className='text-xs'>
性别
{t('gender')}
</Label>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger
@@ -733,19 +742,19 @@ export function ProfileMain() {
size='sm'
className='w-full text-xs h-8'
>
<SelectValue placeholder='选择性别' />
<SelectValue placeholder={t('genderPlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='male'>男</SelectItem>
<SelectItem value='female'>女</SelectItem>
<SelectItem value='secret'>保密</SelectItem>
<SelectItem value='male'>{t('genderMale')}</SelectItem>
<SelectItem value='female'>{t('genderFemale')}</SelectItem>
<SelectItem value='secret'>{t('genderSecret')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<Label htmlFor='website' className='text-xs'>
个人网站
{t('website')}
</Label>
<Input
id='website'
@@ -758,28 +767,28 @@ export function ProfileMain() {
<div className='space-y-1'>
<Label htmlFor='location' className='text-xs'>
所在地
{t('location')}
</Label>
<Input
id='location'
value={location}
onChange={(e) => setLocation(e.target.value)}
className='h-8 text-xs rounded-lg'
placeholder='如:北京'
placeholder={t('locationPlaceholder')}
/>
</div>
</div>
<div className='space-y-1'>
<Label htmlFor='bio' className='text-xs'>
个人简介
{t('bio')}
</Label>
<Textarea
id='bio'
value={bio}
onChange={(e) => setBio(e.target.value)}
className='text-xs min-h-[60px] rounded-lg'
placeholder='介绍一下自己吧...'
placeholder={t('bioPlaceholder')}
/>
</div>
@@ -791,7 +800,7 @@ export function ProfileMain() {
className='h-8 text-xs'
onClick={() => setIsEditDialogOpen(false)}
>
取消
{t('cancel')}
</Button>
<Button
type='submit'
@@ -799,7 +808,9 @@ export function ProfileMain() {
className='h-8 text-xs'
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? '保存中...' : '保存修改'}
{updateProfileMutation.isPending
? t('saving')
: t('saveChanges')}
</Button>
</DialogFooter>
</form>
@@ -810,10 +821,8 @@ export function ProfileMain() {
<Dialog open={isCropDialogOpen} onOpenChange={setIsCropDialogOpen}>
<DialogContent className='max-w-md'>
<DialogHeader>
<DialogTitle>裁剪头像</DialogTitle>
<DialogDescription>
调整头像位置和大小,使其处于居中圆形区域内。
</DialogDescription>
<DialogTitle>{t('cropAvatar')}</DialogTitle>
<DialogDescription>{t('cropAvatarDesc')}</DialogDescription>
</DialogHeader>
{cropFile && (
@@ -839,7 +848,7 @@ export function ProfileMain() {
setCropFile(null);
}}
>
取消
{t('cancel')}
</Button>
</DialogFooter>
</DialogContent>
@@ -44,43 +44,43 @@ import { AuthSourceModal } from '@/components/common/settings/auth-source-modal'
import services from '@/lib/services';
import type { AuthSource, SystemConfig } from '@/lib/services/admin';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
const SECURITY_KEYS = [
{
key: 'password_login_enabled',
title: '允许密码登录',
description: '关闭后仅保留第三方 OIDC 认证源进行系统登录。',
titleKey: 'passwordLoginEnabled',
descKey: 'passwordLoginEnabledDesc',
icon: Lock,
},
{
key: 'registration_enabled',
title: '允许注册',
description: '关闭后系统将禁止新用户进行自主账号注册。',
titleKey: 'registrationEnabled',
descKey: 'registrationEnabledDesc',
icon: UserPlus,
},
{
key: 'password_register_enabled',
title: '允许密码注册',
description: '关闭后只能通过管理员创建或第三方认证关联建号。',
titleKey: 'passwordRegisterEnabled',
descKey: 'passwordRegisterEnabledDesc',
icon: Fingerprint,
},
{
key: 'oidc_login_enabled',
title: '允许 OIDC 登录',
description: '关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。',
titleKey: 'oidcLoginEnabled',
descKey: 'oidcLoginEnabledDesc',
icon: Globe,
},
{
key: 'email_login_verification_enabled',
title: '邮箱登录验证',
description:
'开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。',
titleKey: 'emailLoginVerification',
descKey: 'emailLoginVerificationDesc',
icon: Mail,
},
{
key: 'email_register_verification_enabled',
title: '邮箱注册验证',
description: '开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。',
titleKey: 'emailRegisterVerification',
descKey: 'emailRegisterVerificationDesc',
icon: Mail,
},
] as const;
@@ -92,6 +92,7 @@ interface SecurityTabProps {
export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.security');
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
const [selectedSource, setSelectedSource] = useState<AuthSource | null>(null);
@@ -155,14 +156,14 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('登录状态保持时间已更新');
toast.success(t('loginSessionTTLUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新配置失败');
toast.error(error.message || t('updateConfigFailed'));
},
});
const handleTTLChange = (val: string) => {
const handleSessionTTLChange = (val: string) => {
setSessionTTL(val);
if (val !== 'custom') {
updateTTLMutation.mutate(val);
@@ -172,7 +173,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
const handleCustomBlur = () => {
const parsed = parseInt(customHours, 10);
if (isNaN(parsed) || parsed <= 0) {
toast.error('请输入有效的过期小时数(正整数)');
toast.error(t('invalidHours'));
// 重置为原本的值
const originalVal = configs['login_session_ttl_hours']?.value || '0';
setCustomHours(
@@ -202,10 +203,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('系统安全配置已更新');
toast.success(t('securityConfigUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新配置失败');
toast.error(error.message || t('updateConfigFailed'));
},
});
@@ -220,10 +221,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
toast.success('认证源状态已更新');
toast.success(t('authSourceStatusUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '切换状态失败');
toast.error(error.message || t('toggleStatusFailed'));
},
});
@@ -236,10 +237,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await queryClient.invalidateQueries({
queryKey: ['auth', 'public-sources'],
});
toast.success('认证源已删除');
toast.success(t('authSourceDeleted'));
},
onError: (error: Error) => {
toast.error(error.message || '删除认证源失败');
toast.error(error.message || t('deleteAuthSourceFailed'));
},
});
@@ -270,14 +271,14 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('人机验证配置已成功保存');
toast.success(t('captchaConfigSaved'));
},
onError: (error: Error) => {
toast.error(error.message || '保存配置失败');
toast.error(error.message || t('saveConfigFailed'));
},
});
const handleCapSave = (e: React.FormEvent) => {
const handleCapSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveCapMutation.mutate();
};
@@ -293,10 +294,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
系统登录/注册设置
{t('loginRegistrationSettings')}
</CardTitle>
<CardDescription className='text-xs'>
配置系统的登录限制与用户自主注册权限
{t('loginRegistrationSettingsDesc')}
</CardDescription>
</div>
</div>
@@ -316,11 +317,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<div className='flex items-center gap-2'>
{Icon && <Icon className='size-4 text-primary' />}
<span className='font-medium text-sm text-foreground'>
{item.title}
{t(item.titleKey)}
</span>
</div>
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
{item.description}
{t(item.descKey)}
</p>
</div>
<Switch
@@ -338,11 +339,11 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<div className='flex items-center gap-2'>
<Clock className='size-4 text-primary' />
<span className='font-medium text-sm text-foreground'>
登录状态保持时间
{t('loginSessionTTL')}
</span>
</div>
<p className='text-xs text-muted-foreground leading-relaxed pr-2'>
配置用户登录会话在浏览器中的保持期限。设置为“关闭”则在浏览器关闭后自动退登。
{t('loginSessionTTLDesc')}
</p>
</div>
@@ -350,17 +351,19 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
<Select
value={sessionTTL}
disabled={updateTTLMutation.isPending}
onValueChange={handleTTLChange}
onValueChange={handleSessionTTLChange}
>
<SelectTrigger className='w-[180px] bg-card border-dashed text-xs h-8'>
<SelectValue placeholder='选择保留时间' />
<SelectValue placeholder={t('selectRetentionTime')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='0'>关闭 (浏览器关闭自动退登)</SelectItem>
<SelectItem value='168'>7 天</SelectItem>
<SelectItem value='720'>30 天</SelectItem>
<SelectItem value='-1'>永不过期</SelectItem>
<SelectItem value='custom'>自定义时长</SelectItem>
<SelectItem value='0'>{t('sessionOff')}</SelectItem>
<SelectItem value='168'>{t('session7Days')}</SelectItem>
<SelectItem value='720'>{t('session30Days')}</SelectItem>
<SelectItem value='-1'>
{t('sessionNeverExpire')}
</SelectItem>
<SelectItem value='custom'>{t('sessionCustom')}</SelectItem>
</SelectContent>
</Select>
@@ -376,7 +379,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
handleCustomBlur();
}
}}
placeholder='小时'
placeholder={t('hoursPlaceholder')}
disabled={updateTTLMutation.isPending}
className='w-20 bg-card border-dashed text-xs h-8 px-2'
/>
@@ -396,10 +399,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
认证源管理
{t('authSourceManagement')}
</CardTitle>
<CardDescription className='text-xs'>
添加、修改并启用系统自定义的 OIDC 认证源
{t('authSourceManagementDesc')}
</CardDescription>
</div>
</div>
@@ -413,7 +416,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
variant='secondary'
>
<Plus className='mr-1.5 size-3.5' />
新增认证源
{t('addAuthSource')}
</Button>
</CardHeader>
<CardContent className='pt-6 space-y-3'>
@@ -439,11 +442,12 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}`}
>
{source.is_active ? '已启用' : '已禁用'}
{source.is_active ? t('enabled') : t('disabled')}
</span>
</div>
<div className='text-xs text-muted-foreground font-mono'>
标识符: {source.name} · 类型: {source.type.toUpperCase()}
{t('identifier')}: {source.name} · {t('type')}:{' '}
{source.type.toUpperCase()}
</div>
</div>
<div className='flex items-center gap-4'>
@@ -455,8 +459,8 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
}`}
>
{source.client_secret_configured
? 'Secret 已配置'
: 'Secret 未配置'}
? t('secretConfigured')
: t('secretNotConfigured')}
</span>
<div className='flex items-center gap-2'>
@@ -489,7 +493,9 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
onClick={() => {
if (
window.confirm(
`确定删除认证源「${source.display_name || source.name}」吗?`,
t('deleteAuthSourceConfirm', {
name: source.display_name || source.name,
}),
)
) {
deleteSourceMutation.mutate(source.id);
@@ -504,7 +510,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
))
) : (
<div className='rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
<span>暂无配置的认证源,点击上方按钮新增</span>
<span>{t('noAuthSources')}</span>
<Button
type='button'
size='sm'
@@ -516,7 +522,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='border-dashed'
>
<Plus className='mr-1.5 size-3.5' />
新增认证源
{t('addAuthSource')}
</Button>
</div>
)}
@@ -532,11 +538,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
人机验证配置 (Cap CAPTCHA)
{t('captchaConfig')}
</CardTitle>
<CardDescription className='text-xs'>
配置基于 Proof-of-Work (PoW)
的无感人机验证,保护系统登录免受暴力破解和撞库攻击
{t('captchaConfigDesc')}
</CardDescription>
</div>
</div>
@@ -552,18 +557,18 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
{/* 自动开始计算 Switch */}
<div className='flex items-center justify-between rounded-xl border border-dashed p-4 bg-card mb-4'>
<div className='space-y-0.5'>
<p className='text-sm font-semibold'>打开页面后自动开始计算</p>
<p className='text-sm font-semibold'>{t('autoStartSolving')}</p>
</div>
<Switch checked={capAutoSolve} onCheckedChange={setCapAutoSolve} />
</div>
<form onSubmit={handleCapSave} className='space-y-6'>
<form onSubmit={handleCapSubmit} className='space-y-6'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label
htmlFor='cap_challenge_count'
className='text-xs font-semibold'
>
难题数量 (Count)
{t('challengeCount')}
</Label>
<Input
id='cap_challenge_count'
@@ -576,7 +581,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
客户端需求解的难题总数。默认 1,推荐 1 至 5
{t('challengeCountDesc')}
</p>
</div>
@@ -585,7 +590,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_challenge_difficulty'
className='text-xs font-semibold'
>
验证难度 (Difficulty)
{t('challengeDifficulty')}
</Label>
<Input
id='cap_challenge_difficulty'
@@ -598,7 +603,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
PoW 前缀哈希位数,每加 1 计算时间翻倍。默认 4,推荐 4
{t('challengeDifficultyDesc')}
</p>
</div>
@@ -607,7 +612,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_challenge_size'
className='text-xs font-semibold'
>
盐值长度 (Size)
{t('challengeSize')}
</Label>
<Input
id='cap_challenge_size'
@@ -620,7 +625,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
难题盐值混淆字符长度。默认 32
{t('challengeSizeDesc')}
</p>
</div>
@@ -629,7 +634,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_challenge_ttl'
className='text-xs font-semibold'
>
难题超时时长 (秒)
{t('challengeTTL')}
</Label>
<Input
id='cap_challenge_ttl'
@@ -641,7 +646,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
难题有效期限。默认 600 秒 (10 分钟)
{t('challengeTTLDesc')}
</p>
</div>
@@ -650,7 +655,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
htmlFor='cap_token_ttl'
className='text-xs font-semibold'
>
验证凭证有效时长 (秒)
{t('tokenTTL')}
</Label>
<Input
id='cap_token_ttl'
@@ -662,8 +667,7 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
PoW 计算求解通过后,签发的登录凭证有效时长。默认 1200 秒 (20
分钟)
{t('tokenTTLDesc')}
</p>
</div>
</div>
@@ -677,10 +681,10 @@ export function SecurityTab({ configs, systemConfigsQuery }: SecurityTabProps) {
{saveCapMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
{t('saving')}
</>
) : (
'保存配置'
t('saveConfig')
)}
</Button>
</div>
@@ -31,6 +31,7 @@ import { Badge } from '@/components/ui/badge';
import services from '@/lib/services';
import type { SystemConfig } from '@/lib/services/admin';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
interface SystemTabProps {
configs: Record<string, SystemConfig>;
@@ -39,6 +40,7 @@ interface SystemTabProps {
export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
const queryClient = useQueryClient();
const t = useTranslations('settings.system');
const [serverAddress, setServerAddress] = useState('');
const [siteName, setSiteName] = useState('');
const [smtpHost, setSmtpHost] = useState('');
@@ -65,7 +67,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
const handleDetectAddress = () => {
if (typeof window !== 'undefined') {
setServerAddress(window.location.origin);
toast.success('已自动获取当前域名并填充');
toast.success(t('addressAutoDetected'));
}
};
@@ -85,10 +87,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
queryKey: ['admin', 'system-configs'],
});
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
toast.success('配置已更新');
toast.success(t('configUpdated'));
},
onError: (error: Error) => {
toast.error(error.message || '更新配置失败');
toast.error(error.message || t('updateConfigFailed'));
},
});
@@ -114,14 +116,14 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
}),
queryClient.invalidateQueries({ queryKey: ['public-config'] }),
]);
toast.success('通用配置已成功保存');
toast.success(t('generalConfigSaved'));
},
onError: (error: Error) => {
toast.error(error.message || '保存配置失败');
toast.error(error.message || t('saveConfigFailed'));
},
});
const handleSystemSave = (e: React.FormEvent) => {
const handleSystemSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveSystemMutation.mutate();
};
@@ -156,21 +158,21 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('SMTP 邮件配置已成功保存');
toast.success(t('smtpConfigSaved'));
},
onError: (error: Error) => {
toast.error(error.message || '保存配置失败');
toast.error(error.message || t('saveConfigFailed'));
},
});
const handleSmtpSave = (e: React.FormEvent) => {
const handleSmtpSubmit = (e: React.FormEvent) => {
e.preventDefault();
saveSmtpMutation.mutate();
};
const testSmtpMutation = useMutation({
mutationFn: async () => {
setSmtpTestLog('正在发起连接测试...\n');
setSmtpTestLog(t('connectingTest'));
setSmtpTestSuccess(null);
setSmtpTestError('');
@@ -187,25 +189,27 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
setSmtpTestLog(data.log);
if (data.success) {
setSmtpTestSuccess(true);
toast.success('测试邮件发送成功');
toast.success(t('testEmailSent'));
} else {
setSmtpTestSuccess(false);
setSmtpTestError(data.error || '发送失败,请检查配置和日志。');
toast.error('测试邮件发送失败');
setSmtpTestError(data.error || t('sendFailedCheckConfig'));
toast.error(t('testEmailFailed'));
}
},
onError: (error: Error) => {
setSmtpTestSuccess(false);
setSmtpTestError(error.message || '请求发送失败');
setSmtpTestLog((prev) => prev + `\n[请求错误] ${error.message}\n`);
toast.error(error.message || '测试请求发送失败');
setSmtpTestError(error.message || t('requestFailed'));
setSmtpTestLog(
(prev) => prev + `\n${t('requestError')} ${error.message}\n`,
);
toast.error(error.message || t('testRequestFailed'));
},
});
const handleSmtpTestSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!smtpTestTo) {
toast.error('请输入目标邮箱地址');
toast.error(t('enterTargetEmail'));
return;
}
testSmtpMutation.mutate();
@@ -225,20 +229,20 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
通用设置
{t('generalSettings')}
</CardTitle>
<CardDescription className='text-xs'>
配置站点标识、服务地址与搜索引擎收录策略
{t('generalSettingsDesc')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSystemSave} className='flex flex-col gap-6'>
<form onSubmit={handleSystemSubmit} className='flex flex-col gap-6'>
<div className='grid grid-cols-1 gap-4 lg:grid-cols-2'>
<div className='flex flex-col gap-1.5'>
<Label htmlFor='site_name' className='text-xs font-semibold'>
站点名称
{t('siteName')}
</Label>
<Input
id='site_name'
@@ -249,7 +253,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
用于网页标题、登录注册页面、管理后台与系统邮件。
{t('siteNameDesc')}
</p>
</div>
@@ -259,7 +263,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
htmlFor='server_address'
className='text-xs font-semibold'
>
服务器访问地址
{t('serverAddress')}
</Label>
<Button
type='button'
@@ -269,7 +273,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
className='h-auto px-0 text-xs'
>
<Sparkles data-icon='inline-start' />
使用当前域名
{t('useCurrentDomain')}
</Button>
</div>
<Input
@@ -281,7 +285,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
className='border-dashed bg-card text-xs'
/>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
限定 API 的允许来源;留空将开放任意来源访问。
{t('serverAddressDesc')}
</p>
</div>
</div>
@@ -294,14 +298,16 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
<div className='flex min-w-0 flex-col gap-1'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-xs font-semibold'>
搜索引擎抓取与收录
{t('searchEngineIndexing')}
</span>
<Badge variant={indexingEnabled ? 'default' : 'secondary'}>
{indexingEnabled ? '允许收录' : '禁止收录'}
{indexingEnabled
? t('indexingAllowed')
: t('indexingDenied')}
</Badge>
</div>
<p className='text-[11px] leading-relaxed text-muted-foreground'>
控制 Google、Baidu、Bing 等搜索引擎是否可以索引公开页面。
{t('searchEngineIndexingDesc')}
</p>
</div>
</div>
@@ -324,7 +330,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
<div className='flex flex-col-reverse gap-2 border-t border-dashed pt-4 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex items-center gap-2 text-[11px] text-muted-foreground'>
<Globe className='size-3.5' />
地址变更会影响跨域访问策略,请填写完整协议与域名。
{t('addressChangeWarning')}
</div>
<Button
type='submit'
@@ -337,10 +343,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
data-icon='inline-start'
className='animate-spin'
/>
保存中...
{t('saving')}
</>
) : (
'保存配置'
t('saveConfig')
)}
</Button>
</div>
@@ -357,20 +363,20 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
</div>
<div>
<CardTitle className='text-base font-semibold'>
SMTP 邮件设置
{t('smtpSettings')}
</CardTitle>
<CardDescription className='text-xs'>
配置系统的邮件发送服务 (SMTP)
{t('smtpSettingsDesc')}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className='pt-6'>
<form onSubmit={handleSmtpSave} className='space-y-6'>
<form onSubmit={handleSmtpSubmit} className='space-y-6'>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label htmlFor='smtp_host' className='text-xs font-semibold'>
SMTP 服务器地址
{t('smtpHost')}
</Label>
<Input
id='smtp_host'
@@ -384,7 +390,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
<div className='space-y-1.5'>
<Label htmlFor='smtp_port' className='text-xs font-semibold'>
SMTP 端口
{t('smtpPort')}
</Label>
<Input
id='smtp_port'
@@ -401,7 +407,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
htmlFor='smtp_username'
className='text-xs font-semibold'
>
SMTP 账户
{t('smtpUsername')}
</Label>
<Input
id='smtp_username'
@@ -418,7 +424,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
htmlFor='smtp_password'
className='text-xs font-semibold'
>
SMTP 访问凭证
{t('smtpPassword')}
</Label>
<Input
id='smtp_password'
@@ -427,8 +433,8 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
onChange={(e) => setSmtpPassword(e.target.value)}
placeholder={
configs['smtp_password']?.value === '******'
? '•••••• (已配置,留空或输入新值)'
: '输入凭证密码'
? t('smtpPasswordMasked')
: t('smtpPasswordPlaceholder')
}
className='bg-card border-dashed text-xs'
/>
@@ -449,7 +455,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
}}
disabled={saveSmtpMutation.isPending}
>
测试发件
{t('testSend')}
</Button>
<Button
type='submit'
@@ -459,10 +465,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
{saveSmtpMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
{t('saving')}
</>
) : (
'保存配置'
t('saveConfig')
)}
</Button>
</div>
@@ -474,18 +480,17 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
<DialogContent className='max-w-lg border border-dashed'>
<DialogHeader>
<DialogTitle className='text-base font-semibold'>
SMTP 发件测试
{t('smtpTestTitle')}
</DialogTitle>
<DialogDescription className='text-xs'>
输入接收测试邮件的邮箱地址。系统将使用您在表单中当前填写的 SMTP
配置进行发件测试。
{t('smtpTestDesc')}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSmtpTestSubmit} className='space-y-4'>
<div className='space-y-1.5'>
<Label htmlFor='smtp_test_to' className='text-xs font-semibold'>
目标邮箱地址
{t('targetEmail')}
</Label>
<Input
id='smtp_test_to'
@@ -501,7 +506,9 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
{smtpTestLog && (
<div className='space-y-1.5'>
<Label className='text-xs font-semibold'>连接与传输日志</Label>
<Label className='text-xs font-semibold'>
{t('connectionLog')}
</Label>
<pre className='bg-zinc-950 text-zinc-50 font-mono p-4 rounded-lg text-[10px] h-60 overflow-y-auto whitespace-pre-wrap border border-dashed border-zinc-800 leading-relaxed'>
{smtpTestLog}
</pre>
@@ -510,13 +517,13 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
{smtpTestSuccess === true && (
<div className='p-3 rounded-lg border border-dashed border-emerald-500/30 bg-emerald-500/5 text-emerald-500 text-xs'>
测试成功!邮件已顺利发出。
{t('testSuccess')}
</div>
)}
{smtpTestSuccess === false && (
<div className='p-3 rounded-lg border border-dashed border-rose-500/30 bg-rose-500/5 text-rose-500 text-xs break-all'>
测试失败:{smtpTestError}
{t('testFailed')}: {smtpTestError}
</div>
)}
@@ -528,7 +535,7 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
onClick={() => setSmtpTestOpen(false)}
disabled={testSmtpMutation.isPending}
>
关闭
{t('close')}
</Button>
<Button
type='submit'
@@ -538,10 +545,10 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
{testSmtpMutation.isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
测试中...
{t('testing')}
</>
) : (
'开始测试'
t('startTest')
)}
</Button>
</DialogFooter>
@@ -26,9 +26,11 @@ import {
import services from '@/lib/services';
import type { Template } from '@/lib/services/admin/types';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
export function TemplatesManager() {
const queryClient = useQueryClient();
const t = useTranslations('settings.templates');
const [modalOpen, setModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(
null,
@@ -60,11 +62,11 @@ export function TemplatesManager() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已成功创建');
toast.success(t('templateCreated'));
setModalOpen(false);
},
onError: (error: Error) => {
toast.error(error.message || '创建模板失败');
toast.error(error.message || t('createTemplateFailed'));
},
});
@@ -80,11 +82,11 @@ export function TemplatesManager() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已成功保存');
toast.success(t('templateSaved'));
setModalOpen(false);
},
onError: (error: Error) => {
toast.error(error.message || '修改模板失败');
toast.error(error.message || t('updateTemplateFailed'));
},
});
@@ -94,10 +96,10 @@ export function TemplatesManager() {
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'templates'] });
toast.success('通知模板已删除');
toast.success(t('templateDeleted'));
},
onError: (error: Error) => {
toast.error(error.message || '删除模板失败');
toast.error(error.message || t('deleteTemplateFailed'));
},
});
@@ -145,10 +147,10 @@ export function TemplatesManager() {
</div>
<div>
<CardTitle className='text-base font-semibold'>
通知模板管理
{t('templateManagement')}
</CardTitle>
<CardDescription className='text-xs'>
配置与编辑系统各类场景的通知邮件/短信模板,支持动态变量渲染
{t('templateManagementDesc')}
</CardDescription>
</div>
</div>
@@ -159,7 +161,7 @@ export function TemplatesManager() {
variant='secondary'
>
<Plus className='mr-1.5 size-3.5' />
新增模板
{t('addTemplate')}
</Button>
</CardHeader>
<CardContent className='pt-6 space-y-4'>
@@ -186,18 +188,18 @@ export function TemplatesManager() {
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}`}
>
{tmpl.is_system ? '系统内置' : '自定义'}
{tmpl.is_system ? t('systemBuiltIn') : t('custom')}
</span>
<span className='text-[10px] px-2 py-0.5 rounded-full border border-border/50 bg-muted/50 text-muted-foreground font-mono'>
{tmpl.type.toUpperCase()}
</span>
</div>
<div className='text-xs text-muted-foreground'>
标识符:{' '}
{t('identifier')}:{' '}
<span className='font-mono text-primary bg-primary/5 px-1.5 py-0.5 rounded'>
{tmpl.key}
</span>
{tmpl.subject && ` · 主题: ${tmpl.subject}`}
{tmpl.subject && ` · ${t('subject')}: ${tmpl.subject}`}
</div>
{tmpl.description && (
<p className='text-xs text-muted-foreground/80 leading-relaxed max-w-xl'>
@@ -225,7 +227,9 @@ export function TemplatesManager() {
}
onClick={() => {
if (
window.confirm(`确定删除模板「${tmpl.name}」吗?`)
window.confirm(
t('deleteTemplateConfirm', { name: tmpl.name }),
)
) {
deleteTemplateMutation.mutate(tmpl.key);
}
@@ -239,7 +243,7 @@ export function TemplatesManager() {
</div>
) : (
<div className='rounded-xl border border-dashed border-border/50 px-4 py-8 text-center text-xs text-muted-foreground bg-muted/5 flex flex-col items-center justify-center gap-3'>
<span>暂无配置的通知模板,点击上方按钮新增</span>
<span>{t('noTemplates')}</span>
<Button
type='button'
size='sm'
@@ -248,7 +252,7 @@ export function TemplatesManager() {
className='border-dashed'
>
<Plus className='mr-1.5 size-3.5' />
新增模板
{t('addTemplate')}
</Button>
</div>
)}
@@ -259,11 +263,10 @@ export function TemplatesManager() {
<DialogContent className='max-w-xl border border-dashed'>
<DialogHeader>
<DialogTitle className='text-base font-semibold'>
{selectedTemplate ? '编辑通知模板' : '新增通知模板'}
{selectedTemplate ? t('editTemplate') : t('addTemplateTitle')}
</DialogTitle>
<DialogDescription className='text-xs'>
配置模板渲染逻辑,支持 Go `text/template` 语法(例如 `
{'{{.Code}}'}` 表示验证码变量)。
{t('templateDesc')}
</DialogDescription>
</DialogHeader>
@@ -271,7 +274,7 @@ export function TemplatesManager() {
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
<div className='space-y-1.5'>
<Label htmlFor='tmpl_key' className='text-xs font-semibold'>
模板标识符 (Key)
{t('templateKey')}
</Label>
<Input
id='tmpl_key'
@@ -284,13 +287,13 @@ export function TemplatesManager() {
disabled={!!selectedTemplate}
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
模板的唯一标识,在代码中通过此 Key 调用。
{t('templateKeyDesc')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='tmpl_name' className='text-xs font-semibold'>
模板名称
{t('templateName')}
</Label>
<Input
id='tmpl_name'
@@ -302,13 +305,13 @@ export function TemplatesManager() {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
用于后台识别该模板的可读名称。
{t('templateNameDesc')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='tmpl_type' className='text-xs font-semibold'>
模板类型
{t('templateType')}
</Label>
<Input
id='tmpl_type'
@@ -320,13 +323,13 @@ export function TemplatesManager() {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
模板分类标识,目前支持 `email`。
{t('templateTypeDesc')}
</p>
</div>
<div className='space-y-1.5'>
<Label htmlFor='tmpl_subject' className='text-xs font-semibold'>
模板主题 (Subject)
{t('templateSubject')}
</Label>
<Input
id='tmpl_subject'
@@ -337,7 +340,7 @@ export function TemplatesManager() {
className='bg-card border-dashed text-xs'
/>
<p className='text-[10px] text-muted-foreground leading-normal'>
邮件标题(类型为 email 时生效)。
{t('templateSubjectDesc')}
</p>
</div>
</div>
@@ -347,7 +350,7 @@ export function TemplatesManager() {
htmlFor='tmpl_description'
className='text-xs font-semibold'
>
模板说明与变量描述
{t('templateDescription')}
</Label>
<Input
id='tmpl_description'
@@ -361,7 +364,7 @@ export function TemplatesManager() {
<div className='space-y-1.5'>
<Label htmlFor='tmpl_content' className='text-xs font-semibold'>
模板正文内容 (Content)
{t('templateContent')}
</Label>
<Textarea
id='tmpl_content'
@@ -382,16 +385,16 @@ export function TemplatesManager() {
onClick={() => setModalOpen(false)}
disabled={isPending}
>
取消
{t('cancel')}
</Button>
<Button type='submit' size='sm' disabled={isPending}>
{isPending ? (
<>
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
保存中...
{t('saving')}
</>
) : (
'保存配置'
t('saveConfig')
)}
</Button>
</DialogFooter>
@@ -20,6 +20,7 @@ import {
Upload,
} from 'lucide-react';
import { toast } from 'sonner';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -70,6 +71,7 @@ function formatDate(dateStr: string) {
export function UserFileManager() {
const queryClient = useQueryClient();
const t = useTranslations('files');
const [keyword, setKeyword] = React.useState('');
const [debouncedKeyword, setDebouncedKeyword] = React.useState('');
const [page, setPage] = React.useState(1);
@@ -119,11 +121,11 @@ export function UserFileManager() {
setUploading(true);
},
onSuccess: () => {
toast.success('文件上传成功');
toast.success(t('uploadSuccess'));
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
},
onError: (err: Error) => {
toast.error(err.message || '上传失败');
toast.error(err.message || t('uploadFailed'));
},
onSettled: () => {
setUploading(false);
@@ -137,12 +139,12 @@ export function UserFileManager() {
const deleteMutation = useMutation({
mutationFn: (id: string) => services.upload.deleteMyFile(id),
onSuccess: () => {
toast.success('文件已成功删除');
toast.success(t('deleteSuccess'));
void queryClient.invalidateQueries({ queryKey: ['user-files'] });
setDeleteTarget(null);
},
onError: (err: Error) => {
toast.error(err.message || '删除失败');
toast.error(err.message || t('deleteFailed'));
},
});
@@ -172,7 +174,7 @@ export function UserFileManager() {
<div className='relative flex-1 max-w-md'>
<Search className='absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground' />
<Input
placeholder='搜索文件名...'
placeholder={t('searchPlaceholder')}
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className='pl-9 h-9'
@@ -189,7 +191,7 @@ export function UserFileManager() {
onClick={() => setUploadAccessMode(0)}
>
<Lock className='mr-1 size-3 text-amber-500' />
私有
{t('private')}
</Button>
<Button
variant={uploadAccessMode === 1 ? 'secondary' : 'ghost'}
@@ -198,7 +200,7 @@ export function UserFileManager() {
onClick={() => setUploadAccessMode(1)}
>
<Globe className='mr-1 size-3 text-sky-500' />
公开
{t('public')}
</Button>
</div>
@@ -219,7 +221,7 @@ export function UserFileManager() {
) : (
<Plus className='size-3.5' />
)}
{uploading ? '正在上传...' : '上传文件'}
{uploading ? t('uploading') : t('uploadFile')}
</Button>
</div>
</div>
@@ -236,11 +238,9 @@ export function UserFileManager() {
<Upload className='size-10' />
</div>
<div className='space-y-1'>
<h3 className='font-semibold text-sm'>还没有上传文件</h3>
<h3 className='font-semibold text-sm'>{t('noFiles')}</h3>
<p className='text-xs text-muted-foreground max-w-xs'>
{debouncedKeyword
? '未搜索到匹配的文件'
: '上传您的第一个文件,支持图片、视频、文档和压缩包等格式。'}
{debouncedKeyword ? t('noMatchingFiles') : t('uploadFirstFile')}
</p>
</div>
{!debouncedKeyword && (
@@ -250,7 +250,7 @@ export function UserFileManager() {
size='sm'
className='mt-2 text-xs'
>
选择文件上传
{t('selectFileUpload')}
</Button>
)}
</CardContent>
@@ -284,7 +284,7 @@ export function UserFileManager() {
className='bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-amber-500/20 font-normal'
>
<Lock className='size-2.5' />
私有
{t('private')}
</Badge>
) : (
<Badge
@@ -292,7 +292,7 @@ export function UserFileManager() {
className='bg-sky-500/10 text-sky-600 dark:text-sky-400 text-[10px] gap-1 px-1.5 py-0 rounded-md border border-sky-500/20 font-normal'
>
<Globe className='size-2.5' />
公开
{t('public')}
</Badge>
)}
</div>
@@ -328,7 +328,7 @@ export function UserFileManager() {
size='icon'
variant='ghost'
className='size-7 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground'
title='下载文件'
title={t('downloadFile')}
onClick={() => handleDownload(file)}
>
<Download className='size-3.5' />
@@ -337,7 +337,7 @@ export function UserFileManager() {
size='icon'
variant='ghost'
className='size-7 rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
title='删除文件'
title={t('deleteFile')}
onClick={() => setDeleteTarget(file)}
>
<Trash2 className='size-3.5' />
@@ -361,7 +361,7 @@ export function UserFileManager() {
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className='mr-1 size-3.5' />
上一页
{t('prevPage')}
</Button>
<span className='text-xs text-muted-foreground font-medium px-2'>
{page} / {totalPages}
@@ -373,7 +373,7 @@ export function UserFileManager() {
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
{t('nextPage')}
<ChevronRight className='ml-1 size-3.5' />
</Button>
</div>
@@ -386,18 +386,16 @@ export function UserFileManager() {
>
<AlertDialogContent suppressHydrationWarning>
<AlertDialogHeader>
<AlertDialogTitle>确认删除文件</AlertDialogTitle>
<AlertDialogTitle>{t('confirmDeleteFile')}</AlertDialogTitle>
<AlertDialogDescription>
确定要删除文件{' '}
<span className='font-semibold text-foreground'>
「{deleteTarget?.file_name}」
</span>{' '}
吗?删除后此文件将无法恢复。
{t('confirmDeleteFileDesc', {
name: deleteTarget?.file_name || '',
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{t('cancel')}
</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
@@ -409,7 +407,7 @@ export function UserFileManager() {
{deleteMutation.isPending && (
<Loader2 className='mr-1.5 size-3.5 animate-spin' />
)}
确认删除
{t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+13 -17
View File
@@ -4,20 +4,19 @@ import { cn } from '@/lib/utils';
import { motion } from 'motion/react';
import { Button } from '@/components/ui/button';
import { Book, Check, Copy, Terminal } from 'lucide-react';
import { useTranslations } from 'next-intl';
export interface DeveloperSectionProps {
className?: string;
}
/**
* Developer Section - 开发者体验展示
*/
export const DeveloperSection = React.memo(function DeveloperSection({
className,
}: DeveloperSectionProps) {
const [copied, setCopied] = React.useState(false);
const t = useTranslations('home.developerSection');
const codeContent = `# 注册用户
const codeContent = `# ${t('codeComment')}
curl -X POST https://api.example.com/api/v1/auth/register \\
-H "Content-Type: application/json" \\
-d '{
@@ -32,7 +31,6 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = codeContent;
textArea.style.position = 'fixed';
@@ -97,7 +95,9 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
</div>
<pre className='text-xs sm:text-sm font-mono text-neutral-300 leading-relaxed whitespace-pre-wrap break-all sm:whitespace-pre sm:break-normal overflow-x-auto'>
<code className='block'>
<span className='text-green-400'># 快速开始</span>
<span className='text-green-400'>
# {t('codeQuickStart')}
</span>
{'\n'}
<span className='text-purple-400'>curl</span> -X{' '}
<span className='text-yellow-400'>POST</span>{' '}
@@ -130,22 +130,18 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
transition={{ duration: 0.8 }}
>
<h2 className='text-3xl md:text-5xl font-bold tracking-tight text-foreground leading-[1.1] mb-6'>
开发者友好,
<br />
极速集成上手
{t('heading')}
</h2>
<p className='text-muted-foreground text-lg leading-relaxed mb-8'>
标准化的 RESTful API 接口,完整的 TypeScript
类型定义,详细的文档和 Swagger
UI。无论使用什么编程语言,只需几行代码,即可快速集成。
{t('description')}
</p>
<ul className='space-y-4 mb-8'>
{[
'RESTful API,语义清晰,易于理解',
'OpenAPI / Swagger 接口文档',
'TypeScript 类型定义,开发友好',
'丰富的代码示例和集成指南',
t('features.restful'),
t('features.swagger'),
t('features.typescript'),
t('features.examples'),
].map((item, i) => (
<li
key={i}
@@ -166,7 +162,7 @@ curl -X POST https://api.example.com/api/v1/auth/register \\
className='rounded-full text-xs hover:bg-muted-foreground/10'
>
<Book className='w-3 h-3' />
API 文档
{t('apiDocs')}
</Button>
</Link>
</div>
+37 -28
View File
@@ -2,17 +2,17 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
import Link from 'next/link';
import { Github, LucideIcon, WavesIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
export interface FooterSectionProps {
className?: string;
}
/**
* Footer Section - 页脚
*/
export const FooterSection = React.memo(function FooterSection({
className,
}: FooterSectionProps) {
const t = useTranslations('home.footer');
return (
<footer
className={cn(
@@ -32,8 +32,7 @@ export const FooterSection = React.memo(function FooterSection({
</span>
</Link>
<p className='text-muted-foreground text-base leading-relaxed max-w-sm'>
通用的、现代化的后台管理系统脚手架。基于 Go + React
构建,开箱即用、完整基建、极易扩展。
{t('description')}
</p>
<div className='flex gap-4 pt-2'>
<SocialLink
@@ -44,36 +43,46 @@ export const FooterSection = React.memo(function FooterSection({
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>产品</h3>
<h3 className='font-semibold text-foreground mb-6'>
{t('product')}
</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='/home'>仪表盘</FooterLink>
<FooterLink href='/home'>{t('dashboard')}</FooterLink>
</li>
<li>
<FooterLink href='/settings'>个人设置</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>开发</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='/docs/how-to-use'>快速开始</FooterLink>
</li>
<li>
<FooterLink href='/docs/api'>API 文档</FooterLink>
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet'>
源代码
<FooterLink href='/settings'>
{t('personalSettings')}
</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>社区</h3>
<h3 className='font-semibold text-foreground mb-6'>
{t('development')}
</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='/docs/how-to-use'>
{t('quickStart')}
</FooterLink>
</li>
<li>
<FooterLink href='/docs/api'>{t('apiDocs')}</FooterLink>
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet'>
{t('sourceCode')}
</FooterLink>
</li>
</ul>
</div>
<div className='lg:col-span-1'>
<h3 className='font-semibold text-foreground mb-6'>
{t('community')}
</h3>
<ul className='space-y-4 text-sm text-muted-foreground'>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet/issues'>
@@ -82,7 +91,7 @@ export const FooterSection = React.memo(function FooterSection({
</li>
<li>
<FooterLink href='https://github.com/Rain-kl/Wavelet/discussions'>
讨论
{t('discussions')}
</FooterLink>
</li>
</ul>
@@ -96,13 +105,13 @@ export const FooterSection = React.memo(function FooterSection({
href='/docs/privacy-policy'
className='hover:text-foreground transition-colors'
>
隐私政策
{t('privacyPolicy')}
</Link>
<Link
href='/docs/terms-of-service'
className='hover:text-foreground transition-colors'
>
服务条款
{t('termsOfService')}
</Link>
</div>
</div>
+10 -10
View File
@@ -4,17 +4,17 @@ import { motion } from 'motion/react';
import { ArrowRight, CreditCard, Globe, Shield, Zap } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { useTranslations } from 'next-intl';
export interface HeroSectionProps {
className?: string;
}
/**
* Hero Section - 首页 Hero 展示
*/
export const HeroSection = React.memo(function HeroSection({
className,
}: HeroSectionProps) {
const t = useTranslations('home.hero');
return (
<section className={cn('w-full', className)}>
<motion.div
@@ -57,9 +57,9 @@ export const HeroSection = React.memo(function HeroSection({
}}
className='text-sm md:text-base text-muted-foreground max-w-xl leading-relaxed mb-10'
>
通用的、现代化的后台管理系统脚手架
{t('subtitle')}
<br className='hidden md:block' />
开箱即用、完整基建、极易扩展,助您快速构建应用。
{t('subtitleLine2')}
</motion.p>
<motion.div
@@ -78,7 +78,7 @@ export const HeroSection = React.memo(function HeroSection({
size='lg'
className='w-full rounded-full bg-primary hover:bg-primary/90 font-medium transition-all active:scale-95'
>
立即开始
{t('getStarted')}
<ArrowRight className='size-4' />
</Button>
</Link>
@@ -89,7 +89,7 @@ export const HeroSection = React.memo(function HeroSection({
size='lg'
className='w-full rounded-full font-medium active:scale-95'
>
了解更多
{t('learnMore')}
</Button>
</Link>
</motion.div>
@@ -103,15 +103,15 @@ export const HeroSection = React.memo(function HeroSection({
>
<div className='flex items-center gap-2'>
<Zap className='w-5 h-5 text-yellow-500' />
<span>开箱即用</span>
<span>{t('features.readyToUse')}</span>
</div>
<div className='flex items-center gap-2'>
<Globe className='w-5 h-5 text-blue-500' />
<span>高度可扩展</span>
<span>{t('features.highlyExtensible')}</span>
</div>
<div className='flex items-center gap-2'>
<Shield className='w-5 h-5 text-green-500' />
<span>工业级基建</span>
<span>{t('features.industrialGrade')}</span>
</div>
</motion.div>
</div>
+15 -5
View File
@@ -3,6 +3,7 @@ import { FileTextIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
/**
* 空状态展示组件属性
@@ -55,6 +56,9 @@ export function EmptyState({
className,
iconSize = 'md',
}: EmptyStateProps) {
const t = useTranslations('layout.empty');
const displayTitle = title ?? t('noData');
const displayDescription = description ?? t('noContent');
const iconSizes = { sm: 'size-10', md: 'size-12', lg: 'size-16' };
const iconInnerSizes = { sm: 'size-5', md: 'size-6', lg: 'size-8' };
@@ -76,10 +80,14 @@ export function EmptyState({
/>
</div>
{title && <h3 className='text-base font-medium mb-1'>{title}</h3>}
{displayTitle && (
<h3 className='text-base font-medium mb-1'>{displayTitle}</h3>
)}
{description && (
<p className='text-sm text-muted-foreground max-w-md'>{description}</p>
{displayDescription && (
<p className='text-sm text-muted-foreground max-w-md'>
{displayDescription}
</p>
)}
{onAction && actionText && (
@@ -128,10 +136,12 @@ export function EmptyStateWithBorder(props: EmptyStateProps) {
* @returns {React.ReactNode} 简化版空状态组件
*/
export function EmptyInline({
message = '暂无数据',
message,
icon: Icon = FileTextIcon,
className,
}: Pick<EmptyStateProps, 'icon' | 'className'> & { message?: string }) {
const t = useTranslations('layout.empty');
const displayMessage = message ?? t('noData');
return (
<div
className={cn(
@@ -140,7 +150,7 @@ export function EmptyInline({
)}
>
<Icon className='size-4' />
<span>{message}</span>
<span>{displayMessage}</span>
</div>
);
}
+13 -8
View File
@@ -3,6 +3,7 @@ import { AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useTranslations } from 'next-intl';
/**
* 错误展示组件属性
@@ -48,16 +49,19 @@ export interface ErrorDisplayProps {
* @returns {React.ReactNode} 错误展示组件
*/
export function ErrorDisplay({
title = '加载失败',
title,
message,
error,
onRetry,
retryText = '重试',
retryText,
icon: Icon = AlertCircle,
className,
showStack = false,
}: ErrorDisplayProps) {
const errorMessage = message || error?.message || '发生未知错误,请稍后重试';
const t = useTranslations('layout.error');
const displayTitle = title ?? t('loadFailed');
const displayRetryText = retryText ?? t('retry');
const errorMessage = message || error?.message || t('unknownError');
return (
<div
@@ -70,7 +74,7 @@ export function ErrorDisplay({
<Icon className='size-6 text-red-600 dark:text-red-400' />
</div>
<h3 className='text-lg font-semibold mb-2'>{title}</h3>
<h3 className='text-lg font-semibold mb-2'>{displayTitle}</h3>
<p className='text-sm text-muted-foreground max-w-md mb-4'>
{errorMessage}
@@ -78,14 +82,14 @@ export function ErrorDisplay({
{onRetry && (
<Button onClick={onRetry} variant='outline' className='mt-2'>
{retryText}
{displayRetryText}
</Button>
)}
{showStack && error?.stack && process.env.NODE_ENV === 'development' && (
<details className='mt-6 text-left max-w-2xl w-full'>
<summary className='cursor-pointer text-xs text-muted-foreground hover:text-foreground'>
查看详细错误信息
{t('viewDetails')}
</summary>
<pre className='mt-2 p-4 bg-muted rounded-lg text-xs overflow-auto'>
{error.stack}
@@ -115,7 +119,8 @@ export function ErrorInline({
onRetry,
className,
}: Omit<ErrorDisplayProps, 'title' | 'icon' | 'showStack'>) {
const errorMessage = message || error?.message || '发生未知错误';
const t = useTranslations('layout.error');
const errorMessage = message || error?.message || t('unknownErrorShort');
return (
<div
@@ -133,7 +138,7 @@ export function ErrorInline({
size='sm'
className='h-6 px-4 text-xs'
>
重试
{t('retry')}
</Button>
)}
</div>
+12 -6
View File
@@ -2,6 +2,7 @@ import * as React from 'react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { FileTextIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
/**
* 加载页面组件
@@ -88,12 +89,15 @@ interface LoadingStateProps extends React.ComponentProps<'div'> {
* 用于统一显示加载中的状态
*/
export function LoadingState({
title = '加载中',
description = '正在获取活动数据...',
title,
description,
icon: Icon = FileTextIcon,
className,
iconSize = 'md',
}: LoadingStateProps) {
const t = useTranslations('layout.loading');
const displayTitle = title ?? t('loading');
const displayDescription = description ?? t('loadingDesc');
const iconSizes = { sm: 'size-8', md: 'size-10', lg: 'size-14' };
const iconInnerSizes = { sm: 'size-4', md: 'size-5', lg: 'size-7' };
@@ -115,13 +119,15 @@ export function LoadingState({
/>
</div>
{title && (
<h3 className='text-sm font-medium mb-1 animate-pulse'>{title}</h3>
{displayTitle && (
<h3 className='text-sm font-medium mb-1 animate-pulse'>
{displayTitle}
</h3>
)}
{description && (
{displayDescription && (
<p className='text-xs text-muted-foreground max-w-md animate-pulse'>
{description}
{displayDescription}
</p>
)}
</div>
+44 -31
View File
@@ -14,6 +14,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { type SearchItem, searchItems } from '@/lib/utils/search-data';
import { FileText, Home, Settings, Shield } from 'lucide-react';
import { useUser } from '@/contexts/user-context';
import { useTranslations } from 'next-intl';
interface SearchDialogProps {
open: boolean;
@@ -28,34 +29,43 @@ const categoryIcons = {
};
const categoryLabels = {
page: '页面',
feature: '功能',
setting: '设置',
admin: '管理',
page: 'categoryPage',
feature: 'categoryFeature',
setting: 'categorySetting',
admin: 'categoryAdmin',
};
const getTips = (metaKey: string) => [
const getTips = (metaKey: string, t: (key: string) => string) => [
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 还可以使用</span>
<span className='text-muted-foreground/80 lowercase'>
{t('tipPrefix')}{' '}
</span>
<Kbd className='mx-1'>/</Kbd>
<span className='text-muted-foreground/80 lowercase'>来打开此界面</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 使用</span>
<Kbd className='mx-1'>↑</Kbd>
<Kbd className='mx-1'>↓</Kbd>
<span className='text-muted-foreground/80 lowercase'>来切换选中项</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>Tips: 按住</span>
<Kbd className='mx-1'>{metaKey}</Kbd>
<span className='text-muted-foreground/80 lowercase'>+</span>
<Kbd className='mx-1'>↵</Kbd>
<span className='text-muted-foreground/80 lowercase'>在新标签页打开</span>
<span className='text-muted-foreground/80 lowercase'> {t('tipSlash')}</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>
你知道吗:搜索功能还在持续升级中
{t('tipPrefix')}{' '}
</span>
<Kbd className='mx-1'>↑</Kbd>
<Kbd className='mx-1'>↓</Kbd>
<span className='text-muted-foreground/80 lowercase'> {t('tipArrow')}</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>
{t('tipPrefix')}{' '}
</span>
<Kbd className='mx-1'>{metaKey}</Kbd>
<span className='text-muted-foreground/80 lowercase'>+</span>
<Kbd className='mx-1'>↵</Kbd>
<span className='text-muted-foreground/80 lowercase'>
{' '}
{t('tipNewTab')}
</span>
</>,
<>
<span className='text-muted-foreground/80 lowercase'>
{t('tipDidYouKnow')}
</span>
</>,
];
@@ -63,6 +73,7 @@ const getTips = (metaKey: string) => [
export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
const router = useRouter();
const { user } = useUser();
const t = useTranslations('layout.search');
const [search, setSearch] = useState('');
const [currentTip, setCurrentTip] = useState<React.ReactNode>(null);
const [results, setResults] = useState<SearchItem[]>([]);
@@ -99,11 +110,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
useEffect(() => {
if (open) {
const tips = getTips(metaKey);
const tips = getTips(metaKey, t);
const randomTip = tips[Math.floor(Math.random() * tips.length)];
setCurrentTip(randomTip);
}
}, [open, metaKey]);
}, [open, metaKey, t]);
useEffect(() => {
const items = searchItems(search, user?.is_admin);
@@ -170,18 +181,20 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
className='max-w-[calc(100%-1.5rem)] sm:max-w-[500px] md:max-w-[540px]'
>
<CommandInput
placeholder='搜索页面和功能...'
placeholder={t('searchPlaceholder')}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>没有找到相关内容,换个词试试?</CommandEmpty>
<CommandEmpty>{t('noResults')}</CommandEmpty>
{Object.entries(groupedResults).map(([category, items]) => {
const Icon = categoryIcons[category as keyof typeof categoryIcons];
return (
<CommandGroup
key={category}
heading={categoryLabels[category as keyof typeof categoryLabels]}
heading={t(
categoryLabels[category as keyof typeof categoryLabels],
)}
>
{items.map((item) => (
<CommandItem
@@ -217,11 +230,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
</div>
</div>
<span className='ml-3 shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[10px] leading-none font-medium text-muted-foreground'>
{
{t(
categoryLabels[
item.category as keyof typeof categoryLabels
]
}
],
)}
</span>
</CommandItem>
))}
@@ -235,11 +248,11 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
{isCtrlPressed && <Kbd>{metaKey}</Kbd>}
<Kbd>↵</Kbd>
</KbdGroup>
<span>{isCtrlPressed ? '在新标签页打开' : '打开'}</span>
<span>{isCtrlPressed ? t('openInNewTab') : t('open')}</span>
</div>
<div className='flex items-center gap-1'>
<Kbd>Esc</Kbd>
<span>关闭搜索界面</span>
<span>{t('closeSearch')}</span>
</div>
<div className='ml-auto flex items-center gap-1'>{currentTip}</div>
</div>
@@ -4,9 +4,11 @@ import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';
import { ConfigService } from '@/lib/services/config';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
export function SiteTitleUpdater() {
const pathname = usePathname();
const t = useTranslations('providers.titleUpdater');
const publicConfigQuery = useQuery({
queryKey: ['public-config'],
queryFn: () => ConfigService.getPublicConfig(),
@@ -18,19 +20,19 @@ export function SiteTitleUpdater() {
// Determine the page suffix based on path
let suffix = '';
if (pathname === '/login') {
suffix = ' - 登录';
suffix = ` - ${t('login')}`;
} else if (pathname === '/register') {
suffix = ' - 注册';
suffix = ` - ${t('register')}`;
} else if (pathname.startsWith('/admin')) {
suffix = ' - 后台管理';
suffix = ` - ${t('admin')}`;
} else if (pathname === '/home') {
suffix = ' - 控制台';
suffix = ` - ${t('dashboard')}`;
} else if (pathname === '/') {
suffix = '';
}
document.title = `${siteName}${suffix}`;
}, [publicConfigQuery.data?.site_name, pathname]);
}, [publicConfigQuery.data?.site_name, pathname, t]);
return null;
}
+6 -4
View File
@@ -16,6 +16,7 @@ import type {
} from '@/lib/services/admin';
import services from '@/lib/services';
import { handleContextError } from '@/lib/utils/error-handling';
import { useTranslations } from 'next-intl';
/** Admin 上下文状态接口 */
export interface AdminContextState {
@@ -45,6 +46,7 @@ const AdminContext = createContext<AdminContextState | null>(null);
*/
export function AdminProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const t = useTranslations('contexts.admin');
const [systemConfigs, setSystemConfigs] = useState<SystemConfig[]>([]);
const [systemConfigsLoading, setSystemConfigsLoading] = useState(false);
const [systemConfigsError, setSystemConfigsError] = useState<Error | null>(
@@ -78,14 +80,14 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
return;
}
const errorObject = handleContextError(error, '加载系统配置失败', {
const errorObject = handleContextError(error, t('loadConfigFailed'), {
logError: true,
});
setSystemConfigsError(errorObject);
setSystemConfigsLoading(false);
}
},
[],
[t],
);
/** 更新系统配置 */
@@ -96,11 +98,11 @@ export function AdminProvider({ children }: { children: React.ReactNode }) {
await queryClient.invalidateQueries({ queryKey: ['public-config'] });
await refetchSystemConfigs(lastConfigTypeRef.current);
} catch (error) {
handleContextError(error, '更新系统配置失败');
handleContextError(error, t('updateConfigFailed'));
throw error;
}
},
[queryClient, refetchSystemConfigs],
[queryClient, refetchSystemConfigs, t],
);
const value: AdminContextState = {
+13 -8
View File
@@ -17,6 +17,7 @@ import type {
UpdateUserRequest,
} from '@/lib/services/admin';
import services from '@/lib/services';
import { useTranslations } from 'next-intl';
/** 用户列表查询参数 */
export interface UserQueryParams {
@@ -69,6 +70,7 @@ export function AdminUsersProvider({
}: {
children: React.ReactNode;
}) {
const t = useTranslations('contexts.adminUsers');
// State
const [users, setUsers] = useState<AdminUser[]>([]);
const [total, setTotal] = useState(0);
@@ -237,7 +239,10 @@ export function AdminUsersProvider({
is_active: !user.is_active,
});
toast.success(
`已${!user.is_active ? '启用' : '禁用'}用户 ${user.username}`,
t('userStatusChanged', {
action: !user.is_active ? t('enabled') : t('disabled'),
username: user.username,
}),
);
} catch {
// Revert on error
@@ -246,7 +251,7 @@ export function AdminUsersProvider({
u.id === user.id ? { ...u, is_active: originalStatus } : u,
),
);
toast.error('更新状态失败');
toast.error(t('updateStatusFailed'));
}
};
@@ -261,10 +266,10 @@ export function AdminUsersProvider({
setTotal((prev) => prev + 1);
// Clear cache because data changed
cacheRef.current = {};
toast.success(`已成功创建用户 ${newUser.username}`);
toast.success(t('userCreated', { username: newUser.username }));
return newUser;
} catch (err) {
toast.error(err instanceof Error ? err.message : '创建用户失败');
toast.error(err instanceof Error ? err.message : t('createUserFailed'));
throw err;
}
};
@@ -275,9 +280,9 @@ export function AdminUsersProvider({
setUsers((prev) => prev.filter((u) => u.id !== user.id));
setTotal((prev) => Math.max(0, prev - 1));
cacheRef.current = {};
toast.success(`已删除用户 ${user.username}`);
toast.success(t('userDeleted', { username: user.username }));
} catch (err) {
toast.error(err instanceof Error ? err.message : '删除用户失败');
toast.error(err instanceof Error ? err.message : t('deleteUserFailed'));
throw err;
}
};
@@ -287,9 +292,9 @@ export function AdminUsersProvider({
await services.adminUser.updateUser(id, req);
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, ...req } : u)));
cacheRef.current = {};
toast.success('更新用户信息成功');
toast.success(t('updateUserSuccess'));
} catch (err) {
toast.error(err instanceof Error ? err.message : '更新用户信息失败');
toast.error(err instanceof Error ? err.message : t('updateUserFailed'));
throw err;
}
};
+7 -4
View File
@@ -12,6 +12,7 @@ import {
import { AuthService } from '@/lib/services/auth';
import { User } from '@/lib/services/auth/types';
import { useTranslations } from 'next-intl';
/** 用户状态接口 */
interface UserState {
@@ -52,6 +53,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
});
const isMountedRef = useRef(true);
const t = useTranslations('contexts.user');
/** 获取用户信息 */
const fetchUser = useCallback(async () => {
@@ -68,10 +70,10 @@ export function UserProvider({ children }: { children: ReactNode }) {
setState({
user: null,
loading: false,
error: error instanceof Error ? error.message : '获取用户信息失败',
error: error instanceof Error ? error.message : t('getUserInfoFailed'),
});
}
}, []);
}, [t]);
/** 重新获取用户信息 */
const refetch = useCallback(async () => {
@@ -97,14 +99,15 @@ export function UserProvider({ children }: { children: ReactNode }) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : '登出失败';
const errorMessage =
error instanceof Error ? error.message : t('logoutFailed');
setState((prev) => ({
...prev,
error: errorMessage,
}));
throw new Error(errorMessage);
}
}, []);
}, [t]);
/** 组件挂载时获取用户信息(登录/注册页跳过,避免无意义请求) */
useEffect(() => {
+6 -1
View File
@@ -1,10 +1,14 @@
/**
* API 配置
*
* 浏览器默认走同源相对路径(`/api/...`),由 `next.config` rewrites 反代到后端,
* 避免 dev 下跨域。仅当设置了 `NEXT_PUBLIC_WAVELET_BACKEND_URL` 时浏览器才直连后端
*(静态导出 embed / WebSocket 等场景)。
*/
/**
* 获取 API 基础 URL
* @returns API 基础 URL
* @returns API 基础 URL;浏览器侧空字符串表示同源相对路径
*/
export function getApiBaseUrl(): string {
if (typeof window === 'undefined') {
@@ -14,6 +18,7 @@ export function getApiBaseUrl(): string {
'http://localhost:8000'
);
}
// 空字符串 → axios 请求 `/api/...`,经 Next rewrites 到后端,无 CORS 问题
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || '';
}
+1627 -3
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -17,12 +17,13 @@ function flatten(obj, prefix = '') {
return keys;
}
const zh = JSON.parse(readFileSync(resolve(root, 'messages/zh-CN.json'), 'utf8'));
const zh = JSON.parse(
readFileSync(resolve(root, 'messages/zh-CN.json'), 'utf8'),
);
const en = JSON.parse(readFileSync(resolve(root, 'messages/en.json'), 'utf8'));
const zhKeys = flatten(zh);
const enKeys = flatten(en);
const onlyZh = [...zhKeys].filter((k) => !enKeys.has(k)).sort();
const onlyEn = [...enKeys].filter((k) => !enKeys.has(k) === false && !zhKeys.has(k)).sort();
const onlyEnFixed = [...enKeys].filter((k) => !zhKeys.has(k)).sort();
if (onlyZh.length || onlyEnFixed.length) {