mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -19,7 +21,8 @@ interface LogDetailSheetProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function getResultBadge(result: string) {
|
||||
function ResultBadge({ result }: { result: string }) {
|
||||
const t = useTranslations('applyLogs');
|
||||
if (result === 'success') {
|
||||
return (
|
||||
<Badge
|
||||
@@ -27,7 +30,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
成功
|
||||
{t('success')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +41,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
|
||||
警告
|
||||
{t('warning')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +51,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
|
||||
失败
|
||||
{t('failed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -58,58 +61,57 @@ export function LogDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: LogDetailSheetProps) {
|
||||
const t = useTranslations('applyLogs');
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className='sm:max-w-md w-full p-0 flex flex-col gap-0'>
|
||||
<SheetHeader className='px-5 py-4 border-b'>
|
||||
<SheetTitle>应用日志详情</SheetTitle>
|
||||
<SheetDescription>
|
||||
查看单条应用日志的完整结果与校验信息。
|
||||
</SheetDescription>
|
||||
<SheetTitle>{t('sheetTitle')}</SheetTitle>
|
||||
<SheetDescription>{t('sheetDesc')}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{log ? (
|
||||
<div className='flex-1 overflow-y-auto px-5 py-4 space-y-4'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{getResultBadge(log.result)}
|
||||
<ResultBadge result={log.result} />
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
Node: {log.node_id}
|
||||
</Badge>
|
||||
<Badge variant='outline' className='text-[10px] rounded-full'>
|
||||
版本: {log.version}
|
||||
{t('versionBadge', { version: log.version })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 text-xs'>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>创建时间</p>
|
||||
<p className='text-muted-foreground'>{t('createdAt')}</p>
|
||||
<p className='font-medium'>{formatDateTime(log.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>目标 Checksum</p>
|
||||
<p className='text-muted-foreground'>{t('targetChecksum')}</p>
|
||||
<p className='font-mono break-all'>{log.checksum || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>主配置摘要</p>
|
||||
<p className='text-muted-foreground'>{t('mainChecksum')}</p>
|
||||
<p className='font-mono break-all'>
|
||||
{log.main_config_checksum || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>路由配置摘要</p>
|
||||
<p className='text-muted-foreground'>{t('routeChecksum')}</p>
|
||||
<p className='font-mono break-all'>
|
||||
{log.route_config_checksum || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground'>支持文件数</p>
|
||||
<p className='text-muted-foreground'>{t('supportFiles')}</p>
|
||||
<p className='font-medium'>{log.support_file_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground mb-2'>
|
||||
消息
|
||||
{t('message')}
|
||||
</p>
|
||||
<pre className='text-xs whitespace-pre-wrap break-words'>
|
||||
{log.message || '—'}
|
||||
@@ -120,7 +122,7 @@ export function LogDetailSheet({
|
||||
|
||||
<SheetFooter className='px-5 py-4 border-t'>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { ClipboardList, Eye, RefreshCw, Search, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -49,7 +50,8 @@ function truncateHash(value: string) {
|
||||
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
|
||||
}
|
||||
|
||||
function getResultBadge(result: string) {
|
||||
function ResultBadge({ result }: { result: string }) {
|
||||
const t = useTranslations('applyLogs');
|
||||
if (result === 'success') {
|
||||
return (
|
||||
<Badge
|
||||
@@ -57,7 +59,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0' />
|
||||
成功
|
||||
{t('success')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -68,7 +70,7 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-amber-500/10 border-amber-500/20 text-amber-600 rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-amber-500 rounded-full mr-1.5 shrink-0' />
|
||||
警告
|
||||
{t('warning')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -78,12 +80,14 @@ function getResultBadge(result: string) {
|
||||
className='text-[10px] bg-destructive/10 border-destructive/20 text-destructive rounded-full py-0 px-2'
|
||||
>
|
||||
<span className='size-1 bg-destructive rounded-full mr-1.5 shrink-0' />
|
||||
失败
|
||||
{t('failed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApplyLogsPageClient() {
|
||||
const t = useTranslations('applyLogs');
|
||||
const tc = useTranslations('common');
|
||||
const searchParams = useSearchParams();
|
||||
const initialNodeId = searchParams.get('node_id')?.trim() ?? '';
|
||||
|
||||
@@ -108,12 +112,12 @@ export function ApplyLogsPageClient() {
|
||||
const summary = useMemo(() => {
|
||||
const nodeIds = new Set(rows.map((item) => item.node_id));
|
||||
return [
|
||||
{ label: '总记录数', value: total },
|
||||
{ label: '当前页', value: current },
|
||||
{ label: '总页数', value: totalPage },
|
||||
{ label: '当前页节点数', value: nodeIds.size },
|
||||
{ label: t('totalRecords'), value: total },
|
||||
{ label: t('currentPage'), value: current },
|
||||
{ label: t('totalPages'), value: totalPage },
|
||||
{ label: t('pageNodes'), value: nodeIds.size },
|
||||
];
|
||||
}, [rows, total, current, totalPage]);
|
||||
}, [rows, total, current, totalPage, t]);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -129,11 +133,11 @@ export function ApplyLogsPageClient() {
|
||||
setTotal(data.total);
|
||||
setTotalPage(data.totalPage);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nodeFilter, pageNo, pageSize]);
|
||||
}, [nodeFilter, pageNo, pageSize, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLogs();
|
||||
@@ -162,15 +166,16 @@ export function ApplyLogsPageClient() {
|
||||
setCleaning(true);
|
||||
try {
|
||||
const result = await ApplyLogService.cleanup({ delete_all: true });
|
||||
toast.success('应用日志已清空', {
|
||||
description: `共删除 ${result.deleted_count} 条记录`,
|
||||
toast.success(t('cleared'), {
|
||||
description: t('clearedDesc', { count: result.deleted_count }),
|
||||
});
|
||||
setCleanupOpen(false);
|
||||
setPageNo(1);
|
||||
await fetchLogs();
|
||||
} catch (err) {
|
||||
toast.error('清空应用日志失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
toast.error(t('clearFailed'), {
|
||||
description:
|
||||
err instanceof Error ? err.message : t('unknownError'),
|
||||
});
|
||||
} finally {
|
||||
setCleaning(false);
|
||||
@@ -183,10 +188,10 @@ export function ApplyLogsPageClient() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<ClipboardList className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>应用日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看节点应用配置的成功、警告和失败记录,支持按 node_id 过滤。
|
||||
</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>{t('description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -200,7 +205,7 @@ export function ApplyLogsPageClient() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -209,10 +214,10 @@ export function ApplyLogsPageClient() {
|
||||
disabled={loading || cleaning || total === 0}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清空日志
|
||||
{t('clear')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' asChild>
|
||||
<Link href='/nodes'>返回节点</Link>
|
||||
<Link href='/nodes'>{t('backToNodes')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +239,9 @@ export function ApplyLogsPageClient() {
|
||||
<div className='flex flex-col gap-3 xl:flex-row xl:items-end xl:justify-between'>
|
||||
<div className='grid flex-1 gap-3 md:grid-cols-[minmax(0,1fr)_160px]'>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>Node ID</p>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
{t('nodeId')}
|
||||
</p>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
@@ -243,14 +250,14 @@ export function ApplyLogsPageClient() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}}
|
||||
placeholder='输入 node_id 过滤应用日志'
|
||||
placeholder={t('nodePlaceholder')}
|
||||
className='pl-8 h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
每页条数
|
||||
{t('pageSize')}
|
||||
</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
@@ -265,7 +272,7 @@ export function ApplyLogsPageClient() {
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option} 条
|
||||
{t('pageSizeOption', { count: option })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -274,10 +281,10 @@ export function ApplyLogsPageClient() {
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button size='sm' onClick={handleSearch}>
|
||||
筛选
|
||||
{t('filter')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={handleResetFilters}>
|
||||
重置筛选
|
||||
{t('reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,23 +298,33 @@ export function ApplyLogsPageClient() {
|
||||
<LoadingStateWithBorder />
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无应用日志'
|
||||
description='当前筛选条件下没有可展示的应用记录。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs font-semibold'>Node ID</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>版本</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>结果</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
Checksum
|
||||
{t('colNodeId')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colVersion')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colResult')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colChecksum')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colTime')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>
|
||||
{t('colMessage')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>时间</TableHead>
|
||||
<TableHead className='text-xs font-semibold'>消息</TableHead>
|
||||
<TableHead className='text-xs font-semibold text-right'>
|
||||
操作
|
||||
{t('colActions')}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -323,7 +340,9 @@ export function ApplyLogsPageClient() {
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{log.version}
|
||||
</TableCell>
|
||||
<TableCell>{getResultBadge(log.result)}</TableCell>
|
||||
<TableCell>
|
||||
<ResultBadge result={log.result} />
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className='text-xs font-mono text-muted-foreground'
|
||||
title={log.checksum}
|
||||
@@ -349,7 +368,7 @@ export function ApplyLogsPageClient() {
|
||||
}}
|
||||
>
|
||||
<Eye className='size-3 mr-1' />
|
||||
详情
|
||||
{t('detail')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -362,7 +381,11 @@ export function ApplyLogsPageClient() {
|
||||
{!loading && rows.length > 0 ? (
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
第 {current} / {Math.max(totalPage, 1)} 页,共 {total} 条记录。
|
||||
{t('pageInfo', {
|
||||
current,
|
||||
total: Math.max(totalPage, 1),
|
||||
count: total,
|
||||
})}
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
@@ -371,7 +394,7 @@ export function ApplyLogsPageClient() {
|
||||
disabled={current <= 1}
|
||||
onClick={() => setPageNo((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
上一页
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -383,7 +406,7 @@ export function ApplyLogsPageClient() {
|
||||
)
|
||||
}
|
||||
>
|
||||
下一页
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,14 +421,15 @@ export function ApplyLogsPageClient() {
|
||||
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认清空应用日志?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('clearTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
该操作将删除全部 {total}{' '}
|
||||
条应用日志记录,且不可撤销。清空后节点仍会照常上报新的应用结果。
|
||||
{t('clearDesc', { count: total })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={cleaning}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={cleaning}>
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
disabled={cleaning}
|
||||
@@ -414,7 +438,7 @@ export function ApplyLogsPageClient() {
|
||||
void handleCleanupLogs();
|
||||
}}
|
||||
>
|
||||
{cleaning ? '清空中...' : '确认清空'}
|
||||
{cleaning ? t('clearing') : t('confirmClear')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
Reference in New Issue
Block a user