Files
OpenFlare/frontend/app/(main)/admin/database/components/table-browser.tsx
T
2026-08-26 15:13:23 +08:00

269 lines
8.8 KiB
TypeScript

'use client';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import type { TableDataResponse } from '@/lib/services/db-manage';
import { DbManageService } from '@/lib/services/db-manage';
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
};
/**
* 格式化表格单元格内容
*/
const formatCellValue = (val: unknown): string => {
if (val === null || val === undefined) return '-';
if (typeof val === 'boolean') return val ? 'true' : 'false';
if (typeof val === 'object') {
try {
return JSON.stringify(val);
} catch {
return '[Object]';
}
}
return String(val);
};
interface TableBrowserProps {
tables: string[];
loadingTables: boolean;
refreshTrigger: number;
}
export function TableBrowser({
tables,
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);
const [loadingData, setLoadingData] = useState<boolean>(false);
const pageSize = 10;
// 获取具体表数据
const fetchTableData = useCallback(
async (tableName: string, targetPage: number, size: number) => {
if (!tableName) return;
setLoadingData(true);
try {
const data = await DbManageService.getTableData({
table: tableName,
page: targetPage,
pageSize: size,
});
setTableData(data);
} catch (err) {
toast.error(t('fetchTableFailed', { table: tableName }), {
description: err instanceof Error ? err.message : t('unknownError'),
});
} finally {
setLoadingData(false);
}
},
[t],
);
// 默认选择第一张表
useEffect(() => {
if (tables.length > 0 && !selectedTable) {
setSelectedTable(tables[0]);
}
}, [tables, selectedTable]);
// 当选中的表、页码或外部刷新触发器改变时获取数据
useEffect(() => {
if (selectedTable) {
fetchTableData(selectedTable, page, pageSize);
}
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData]);
// 处理页码改变
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
// 处理表切换
const handleTableChange = (value: string) => {
setSelectedTable(value);
setPage(1); // 切换表重置为第一页
};
return (
<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'>
{t('tableBrowser')}
</CardTitle>
<CardDescription className='text-[11px]'>
{t('tableBrowserDesc')}
</CardDescription>
</div>
<div className='flex items-center gap-2'>
{loadingTables ? (
<Skeleton className='h-8 w-48' />
) : (
<Select value={selectedTable} onValueChange={handleTableChange}>
<SelectTrigger
aria-label={t('selectTable')}
className='h-8 w-[200px] text-xs bg-background border-border/40'
>
<SelectValue placeholder={t('selectTable')} />
</SelectTrigger>
<SelectContent className='max-h-[300px]'>
{tables.map((t) => (
<SelectItem key={t} value={t} className='text-xs font-mono'>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</CardHeader>
<CardContent className='pt-4'>
{loadingData && !tableData ? (
<div className='space-y-3 py-6'>
<Skeleton className='h-6 w-full' />
<Skeleton className='h-10 w-full' />
<Skeleton className='h-10 w-full' />
<Skeleton className='h-10 w-full' />
</div>
) : tableData && tableData.columns.length > 0 ? (
<div className='space-y-4'>
{/* 数据表格区域 */}
<div className='border rounded-md overflow-x-auto max-w-full bg-background/50 relative'>
{loadingData && (
<div className='absolute inset-0 bg-background/40 backdrop-blur-[1px] flex items-center justify-center z-10'>
<RefreshCw className='size-5 text-primary animate-spin' />
</div>
)}
<Table className='text-xs'>
<TableHeader className='bg-muted/40 font-semibold sticky top-0'>
<TableRow>
{tableData.columns.map((col) => (
<TableHead
key={col}
className='font-semibold text-foreground py-2.5'
>
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{tableData.results && tableData.results.length > 0 ? (
tableData.results.map((row, rIndex) => (
<TableRow key={rIndex} className='hover:bg-muted/10'>
{tableData.columns.map((col) => (
<TableCell key={col} className='font-mono py-2'>
<span
className='truncate max-w-[200px] block'
title={formatCellValue(row[col])}
>
{formatCellValue(row[col])}
</span>
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={tableData.columns.length}
className='text-center py-10 text-muted-foreground'
>
{t('noRecordsInTable')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* 分页控制 */}
<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
variant='outline'
size='sm'
className='h-7 px-2 text-[11px]'
onClick={() => handlePageChange(page - 1)}
disabled={page <= 1 || loadingData}
>
{t('prevPage')}
</Button>
<span className='text-xs px-2 font-mono'>
{t('pageIndicator', {
current: page,
total: Math.max(1, Math.ceil(tableData.total / pageSize)),
})}
</span>
<Button
variant='outline'
size='sm'
className='h-7 px-2 text-[11px]'
onClick={() => handlePageChange(page + 1)}
disabled={
page >= Math.ceil(tableData.total / pageSize) || loadingData
}
>
{t('nextPage')}
</Button>
</div>
</div>
</div>
) : (
<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'>{t('noTableSelected')}</span>
</div>
)}
</CardContent>
</Card>
);
}