mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 14:26:36 +08:00
5a8722ff07
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
372 lines
13 KiB
TypeScript
372 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import dynamic from 'next/dynamic';
|
|
import * as React from 'react';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Activity,
|
|
Cpu,
|
|
Database,
|
|
Download,
|
|
FileText,
|
|
HardDrive,
|
|
Layers,
|
|
RefreshCw,
|
|
Server,
|
|
Terminal,
|
|
} from 'lucide-react';
|
|
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import services from '@/lib/services';
|
|
import type { DBOverview } from '@/lib/services/db-manage';
|
|
|
|
const sectionFallback = (
|
|
<div className='h-48 animate-pulse rounded-lg border border-border/40 bg-muted/20' />
|
|
);
|
|
|
|
const TableBrowser = dynamic(
|
|
() => import('./components/table-browser').then((mod) => mod.TableBrowser),
|
|
{ loading: () => sectionFallback },
|
|
);
|
|
|
|
const CacheManager = dynamic(
|
|
() => import('./components/cache-manager').then((mod) => mod.CacheManager),
|
|
{ loading: () => sectionFallback },
|
|
);
|
|
|
|
const SQLConsole = dynamic(
|
|
() => import('./components/sql-console').then((mod) => mod.SQLConsole),
|
|
{ ssr: false },
|
|
);
|
|
|
|
/**
|
|
* 格式化数字,每3位加逗号
|
|
*/
|
|
const formatNumber = (num: number | string) => {
|
|
if (num === undefined || num === null) return '0';
|
|
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
};
|
|
|
|
export function DatabasePageClient() {
|
|
const t = useTranslations('admin.database');
|
|
// 核心状态
|
|
const [overview, setOverview] = useState<DBOverview | null>(null);
|
|
const [tables, setTables] = useState<string[]>([]);
|
|
const [loadingOverview, setLoadingOverview] = useState<boolean>(true);
|
|
const [loadingTables, setLoadingTables] = useState<boolean>(true);
|
|
const [exporting, setExporting] = useState<boolean>(false);
|
|
|
|
// 协调子组件刷新与视图切换状态
|
|
const [showConsole, setShowConsole] = useState<boolean>(false);
|
|
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(t('fetchOverviewFailed'), {
|
|
description: err instanceof Error ? err.message : t('unknownError'),
|
|
});
|
|
} finally {
|
|
setLoadingOverview(false);
|
|
}
|
|
}, [t]);
|
|
|
|
// 2. 获取表列表
|
|
const fetchTables = useCallback(async () => {
|
|
setLoadingTables(true);
|
|
try {
|
|
const data = await services.dbManage.listTables();
|
|
setTables(data);
|
|
} catch (err) {
|
|
toast.error(t('fetchTablesFailed'), {
|
|
description: err instanceof Error ? err.message : t('unknownError'),
|
|
});
|
|
} finally {
|
|
setLoadingTables(false);
|
|
}
|
|
}, [t]);
|
|
|
|
// 3. 协调刷新
|
|
const handleRefreshAll = () => {
|
|
fetchOverview();
|
|
fetchTables();
|
|
setRefreshTrigger((prev) => prev + 1);
|
|
};
|
|
|
|
// 4. 导出数据库备份
|
|
const handleExport = async () => {
|
|
setExporting(true);
|
|
try {
|
|
const { blob, filename } = await services.dbManage.exportDatabase();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
toast.success(t('exportSuccess'), {
|
|
description: t('exportSuccessDesc', { filename }),
|
|
});
|
|
} catch (err) {
|
|
toast.error(t('exportFailed'), {
|
|
description: err instanceof Error ? err.message : t('exportException'),
|
|
});
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
// 初始化加载
|
|
useEffect(() => {
|
|
fetchOverview();
|
|
fetchTables();
|
|
}, [fetchOverview, fetchTables]);
|
|
|
|
// 渲染概览卡片骨架
|
|
const renderOverviewSkeleton = () => (
|
|
<div className='grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4'>
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Card key={i} className='border-border/40 bg-card/50'>
|
|
<CardHeader className='pb-2'>
|
|
<Skeleton className='h-4 w-16' />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className='h-6 w-24' />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// 渲染 SQL 查询控台全屏视图
|
|
if (showConsole) {
|
|
return (
|
|
<SQLConsole
|
|
dbType={overview?.type}
|
|
onClose={() => setShowConsole(false)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 正常页面排版布局
|
|
return (
|
|
<div className='py-6 px-1 space-y-6 w-full'>
|
|
{/* 顶部控制与标题 */}
|
|
<div className='flex flex-col sm:flex-row sm:items-center sm:justify-between pb-4 gap-4'>
|
|
<div className='flex items-center gap-2'>
|
|
<Database className='size-5 text-primary' />
|
|
<div>
|
|
<h1 className='text-2xl font-semibold tracking-tight'>
|
|
{t('pageTitle')}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
size='sm'
|
|
variant='secondary'
|
|
className='h-8 gap-1.5 text-xs self-start sm:self-auto'
|
|
onClick={handleRefreshAll}
|
|
disabled={loadingOverview}
|
|
>
|
|
<RefreshCw
|
|
className={`size-3 ${loadingOverview ? 'animate-spin' : ''}`}
|
|
/>
|
|
{t('refreshData')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 1. 概览指标卡片组 */}
|
|
{loadingOverview
|
|
? renderOverviewSkeleton()
|
|
: overview && (
|
|
<div className='grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4'>
|
|
{/* 卡片1: 数据库类型 */}
|
|
<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>
|
|
<CardContent>
|
|
<div className='text-sm font-bold uppercase'>
|
|
{overview.type}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 卡片2: 数据库版本 */}
|
|
<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>
|
|
<CardContent>
|
|
<div
|
|
className='text-xs font-semibold truncate'
|
|
title={overview.version}
|
|
>
|
|
{overview.version.split(' ').slice(0, 2).join(' ')}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 卡片3: 数据库名称 */}
|
|
<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>
|
|
<CardContent>
|
|
<div
|
|
className='text-xs font-semibold truncate'
|
|
title={overview.name}
|
|
>
|
|
{overview.name.substring(
|
|
overview.name.lastIndexOf('/') + 1,
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 卡片4: 数据库大小 */}
|
|
<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>
|
|
<CardContent>
|
|
<div className='text-sm font-bold'>{overview.size}</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 卡片5: 表数量 */}
|
|
<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>
|
|
<CardContent>
|
|
<div className='text-sm font-bold'>
|
|
{formatNumber(overview.table_count)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 卡片6: 连接数 */}
|
|
<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>
|
|
<CardContent>
|
|
<div className='text-sm font-bold'>
|
|
{formatNumber(overview.connections)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* 2. 数据表浏览器区块 */}
|
|
<TableBrowser
|
|
tables={tables}
|
|
loadingTables={loadingTables}
|
|
refreshTrigger={refreshTrigger}
|
|
/>
|
|
|
|
{/* 3. 缓存管理区块 */}
|
|
<CacheManager refreshTrigger={refreshTrigger} />
|
|
|
|
{/* 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'>
|
|
{t('featureArea')}
|
|
</CardTitle>
|
|
<CardDescription className='text-[11px]'>
|
|
{t('featureAreaDesc')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className='pt-4'>
|
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
|
{/* 功能一: 数据库导出 */}
|
|
<div className='flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150'>
|
|
<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'>
|
|
{t('dbBackupExportDesc')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size='sm'
|
|
variant='outline'
|
|
className='h-8 text-xs gap-1'
|
|
onClick={handleExport}
|
|
disabled={exporting}
|
|
>
|
|
<RefreshCw
|
|
className={`size-3 ${exporting ? 'animate-spin' : ''}`}
|
|
/>
|
|
{exporting ? t('preparing') : t('startExport')}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 功能二: SQL 查询终端 */}
|
|
<div className='flex items-center justify-between p-4 border border-dashed rounded-lg hover:bg-muted/20 transition-colors duration-150'>
|
|
<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' />
|
|
{t('sqlQueryConsole')}
|
|
</p>
|
|
<p className='text-[10px] text-muted-foreground'>
|
|
{t('sqlQueryConsoleDesc')}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size='sm'
|
|
className='h-8 text-xs gap-1 bg-primary text-primary-foreground hover:bg-primary/95'
|
|
onClick={() => {
|
|
setShowConsole(true);
|
|
}}
|
|
>
|
|
<FileText className='size-3.5' />
|
|
{t('enterConsole')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|