mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
prettier
This commit is contained in:
@@ -1,16 +1,22 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {HardDrive, RefreshCw, Trash2} from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { HardDrive, RefreshCw, Trash2 } 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 {Label} from "@/components/ui/label"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Switch} from "@/components/ui/switch"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,183 +24,205 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type {CacheStatus} from "@/lib/services/admin"
|
||||
import services from "@/lib/services"
|
||||
} from '@/components/ui/dialog';
|
||||
import type { CacheStatus } from '@/lib/services/admin';
|
||||
import services from '@/lib/services';
|
||||
|
||||
/**
|
||||
* 格式化字节大小
|
||||
*/
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes"
|
||||
const k = 1024
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]
|
||||
}
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化数字,每3位加逗号
|
||||
*/
|
||||
const formatNumber = (num: number | string) => {
|
||||
if (num === undefined || num === null) return "0"
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ",")
|
||||
}
|
||||
if (num === undefined || num === null) return '0';
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
|
||||
};
|
||||
|
||||
interface CacheManagerProps {
|
||||
refreshTrigger: number
|
||||
refreshTrigger: number;
|
||||
}
|
||||
|
||||
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null)
|
||||
const [loadingCache, setLoadingCache] = useState<boolean>(true)
|
||||
const [savingConfig, setSavingConfig] = useState<boolean>(false)
|
||||
const [clearingCache, setClearingCache] = useState<boolean>(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState<boolean>(false)
|
||||
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
|
||||
const [loadingCache, setLoadingCache] = useState<boolean>(true);
|
||||
const [savingConfig, setSavingConfig] = useState<boolean>(false);
|
||||
const [clearingCache, setClearingCache] = useState<boolean>(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState<boolean>(false);
|
||||
|
||||
// 策略配置表单状态
|
||||
const [maxSizeMB, setMaxSizeMB] = useState<string>("100")
|
||||
const [ttlMinutes, setTtlMinutes] = useState<string>("60")
|
||||
const [lruEnabled, setLruEnabled] = useState<boolean>(true)
|
||||
const [maxSizeMB, setMaxSizeMB] = useState<string>('100');
|
||||
const [ttlMinutes, setTtlMinutes] = useState<string>('60');
|
||||
const [lruEnabled, setLruEnabled] = useState<boolean>(true);
|
||||
|
||||
// 获取磁盘缓存状态
|
||||
const fetchCacheStatus = useCallback(async (isSilent = false) => {
|
||||
if (!isSilent) setLoadingCache(true)
|
||||
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)
|
||||
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 : "未知错误",
|
||||
})
|
||||
toast.error('获取磁盘缓存状态失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setLoadingCache(false)
|
||||
setLoadingCache(false);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// 保存配置
|
||||
const handleSaveConfig = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const size = parseInt(maxSizeMB, 10)
|
||||
const ttl = parseInt(ttlMinutes, 10)
|
||||
e.preventDefault();
|
||||
const size = parseInt(maxSizeMB, 10);
|
||||
const ttl = parseInt(ttlMinutes, 10);
|
||||
if (isNaN(size) || size < 1) {
|
||||
toast.error("保存失败", { description: "最大容量限制必须是大于等于 1 的整数" })
|
||||
return
|
||||
toast.error('保存失败', {
|
||||
description: '最大容量限制必须是大于等于 1 的整数',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isNaN(ttl) || ttl < 0) {
|
||||
toast.error("保存失败", { description: "默认过期时间必须是大于等于 0 的整数" })
|
||||
return
|
||||
toast.error('保存失败', {
|
||||
description: '默认过期时间必须是大于等于 0 的整数',
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSavingConfig(true)
|
||||
setSavingConfig(true);
|
||||
try {
|
||||
await services.adminCache.updateCacheConfig({
|
||||
max_size_mb: size,
|
||||
ttl_minutes: ttl,
|
||||
lru_enabled: lruEnabled,
|
||||
})
|
||||
toast.success("保存成功", { description: "磁盘缓存策略已热更新" })
|
||||
await fetchCacheStatus(true)
|
||||
});
|
||||
toast.success('保存成功', { description: '磁盘缓存策略已热更新' });
|
||||
await fetchCacheStatus(true);
|
||||
} catch (err) {
|
||||
toast.error("保存配置失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('保存配置失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setSavingConfig(false)
|
||||
setSavingConfig(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 清空磁盘缓存数据
|
||||
const handleClearCache = async () => {
|
||||
setClearingCache(true)
|
||||
setClearingCache(true);
|
||||
try {
|
||||
await services.adminCache.clearCache()
|
||||
toast.success("清空成功", { description: "缓存数据已全部清除" })
|
||||
setShowClearConfirm(false)
|
||||
await fetchCacheStatus(true)
|
||||
await services.adminCache.clearCache();
|
||||
toast.success('清空成功', { description: '缓存数据已全部清除' });
|
||||
setShowClearConfirm(false);
|
||||
await fetchCacheStatus(true);
|
||||
} catch (err) {
|
||||
toast.error("清空缓存失败", {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
toast.error('清空缓存失败', {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setClearingCache(false)
|
||||
setClearingCache(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化拉取
|
||||
useEffect(() => {
|
||||
fetchCacheStatus()
|
||||
}, [fetchCacheStatus])
|
||||
fetchCacheStatus();
|
||||
}, [fetchCacheStatus]);
|
||||
|
||||
// 当外部刷新触发器改变时自动刷新
|
||||
useEffect(() => {
|
||||
fetchCacheStatus(true)
|
||||
}, [refreshTrigger, fetchCacheStatus])
|
||||
fetchCacheStatus(true);
|
||||
}, [refreshTrigger, fetchCacheStatus]);
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="size-4 text-primary animate-pulse" />
|
||||
<CardTitle className="text-sm font-semibold">缓存管理</CardTitle>
|
||||
<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'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<HardDrive className='size-4 text-primary animate-pulse' />
|
||||
<CardTitle className='text-sm font-semibold'>缓存管理</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="text-[11px]">管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略</CardDescription>
|
||||
<CardDescription className='text-[11px]'>
|
||||
管理和监控系统级磁盘缓存的资源占用、生命周期及淘汰策略
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 gap-1.5 text-xs self-start md:self-auto"
|
||||
size='sm'
|
||||
variant='secondary'
|
||||
className='h-8 gap-1.5 text-xs self-start md:self-auto'
|
||||
onClick={() => fetchCacheStatus(true)}
|
||||
disabled={loadingCache}
|
||||
>
|
||||
<RefreshCw className={`size-3 ${loadingCache ? "animate-spin" : ""}`} />
|
||||
<RefreshCw
|
||||
className={`size-3 ${loadingCache ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新状态
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<CardContent className='pt-4'>
|
||||
{loadingCache && !cacheStatus ? (
|
||||
<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" />
|
||||
<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' />
|
||||
</div>
|
||||
) : cacheStatus ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
<div className='grid grid-cols-1 lg:grid-cols-5 gap-6'>
|
||||
{/* 左边:状态区 (2/5 cols) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">运行状态</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className='lg:col-span-2 space-y-4'>
|
||||
<h4 className='text-xs font-semibold text-muted-foreground uppercase tracking-wider'>
|
||||
运行状态
|
||||
</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">已用空间</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">
|
||||
<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'>
|
||||
已用空间
|
||||
</p>
|
||||
<p className='text-xl font-bold tracking-tight text-foreground'>
|
||||
{formatBytes(cacheStatus.total_size)}
|
||||
</p>
|
||||
</div>
|
||||
{/* 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">缓存键数量</p>
|
||||
<p className="text-xl font-bold tracking-tight text-foreground">
|
||||
{formatNumber(cacheStatus.keys_count)} <span className="text-xs text-muted-foreground font-normal">个文件</span>
|
||||
<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'>
|
||||
缓存键数量
|
||||
</p>
|
||||
<p className='text-xl font-bold tracking-tight text-foreground'>
|
||||
{formatNumber(cacheStatus.keys_count)}{' '}
|
||||
<span className='text-xs text-muted-foreground font-normal'>
|
||||
个文件
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 存储路径 */}
|
||||
<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">缓存基准目录</p>
|
||||
<code className="text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate" title={cacheStatus.base_path}>
|
||||
<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'>
|
||||
缓存基准目录
|
||||
</p>
|
||||
<code
|
||||
className='text-xs font-mono bg-muted/60 px-2 py-1 rounded-md block truncate'
|
||||
title={cacheStatus.base_path}
|
||||
>
|
||||
{cacheStatus.base_path}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-xs font-medium w-full"
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-8 text-xs font-medium w-full'
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
>
|
||||
立即清空缓存
|
||||
@@ -202,111 +230,125 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
|
||||
</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">策略配置</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)</Label>
|
||||
<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'>
|
||||
策略配置
|
||||
</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)
|
||||
</Label>
|
||||
<Input
|
||||
id="maxSizeMB"
|
||||
type="number"
|
||||
min="1"
|
||||
id='maxSizeMB'
|
||||
type='number'
|
||||
min='1'
|
||||
value={maxSizeMB}
|
||||
onChange={(e) => setMaxSizeMB(e.target.value)}
|
||||
className="h-8 text-xs bg-background/50 border-border/40"
|
||||
placeholder="例如 100"
|
||||
className='h-8 text-xs bg-background/50 border-border/40'
|
||||
placeholder='例如 100'
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">当总大小超出该值时,自动触发淘汰算法</p>
|
||||
<p className='text-[9px] text-muted-foreground'>
|
||||
当总大小超出该值时,自动触发淘汰算法
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="ttlMinutes" className="text-xs font-medium">生存时间限制 (分钟)</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='ttlMinutes' className='text-xs font-medium'>
|
||||
生存时间限制 (分钟)
|
||||
</Label>
|
||||
<Input
|
||||
id="ttlMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
id='ttlMinutes'
|
||||
type='number'
|
||||
min='0'
|
||||
value={ttlMinutes}
|
||||
onChange={(e) => setTtlMinutes(e.target.value)}
|
||||
className="h-8 text-xs bg-background/50 border-border/40"
|
||||
placeholder="例如 60,0 表示永不过期"
|
||||
className='h-8 text-xs bg-background/50 border-border/40'
|
||||
placeholder='例如 60,0 表示永不过期'
|
||||
required
|
||||
/>
|
||||
<p className="text-[9px] text-muted-foreground">缓存项的最长存活时间,超期后将失效被清理</p>
|
||||
<p className='text-[9px] text-muted-foreground'>
|
||||
缓存项的最长存活时间,超期后将失效被清理
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between p-4 rounded-xl border border-border/40 bg-background/20">
|
||||
<div className="space-y-1 pr-4">
|
||||
<Label htmlFor="lruEnabled" className="text-xs font-semibold block cursor-pointer">
|
||||
<div className='flex items-start justify-between p-4 rounded-xl border border-border/40 bg-background/20'>
|
||||
<div className='space-y-1 pr-4'>
|
||||
<Label
|
||||
htmlFor='lruEnabled'
|
||||
className='text-xs font-semibold block cursor-pointer'
|
||||
>
|
||||
启用 LRU 淘汰机制
|
||||
</Label>
|
||||
<span className="text-[10px] text-muted-foreground block">
|
||||
<span className='text-[10px] text-muted-foreground block'>
|
||||
在到达最大容量限制时,自动移除最久未被访问的缓存项。关闭该功能仅清理过期项。
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id="lruEnabled"
|
||||
id='lruEnabled'
|
||||
checked={lruEnabled}
|
||||
onCheckedChange={setLruEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<div className='flex justify-end pt-2'>
|
||||
<Button
|
||||
type="submit"
|
||||
type='submit'
|
||||
disabled={savingConfig}
|
||||
className="h-8 text-xs px-4"
|
||||
className='h-8 text-xs px-4'
|
||||
>
|
||||
{savingConfig ? "正在保存..." : "保存设置"}
|
||||
{savingConfig ? '正在保存...' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* 清除缓存确认对话框 */}
|
||||
<Dialog open={showClearConfirm} onOpenChange={setShowClearConfirm}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className='sm:max-w-md'>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-semibold flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="size-4" />
|
||||
<DialogTitle className='text-sm font-semibold flex items-center gap-2 text-destructive'>
|
||||
<Trash2 className='size-4' />
|
||||
确认清空所有缓存?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground pt-1">
|
||||
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为 0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
|
||||
<DialogDescription className='text-xs text-muted-foreground pt-1'>
|
||||
该操作将彻底清空磁盘目录下的全部缓存文件(包含临时解压、处理后的图片及各种块文件),重置键数量统计为
|
||||
0。此操作不可撤销,且可能导致用户拉取资源时出现一过性的响应变慢。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 justify-end mt-4">
|
||||
<DialogFooter className='flex gap-2 justify-end mt-4'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 text-xs'
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
className='h-8 text-xs'
|
||||
onClick={handleClearCache}
|
||||
disabled={clearingCache}
|
||||
>
|
||||
{clearingCache ? "清理中..." : "确认清空"}
|
||||
{clearingCache ? '清理中...' : '确认清空'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,182 +1,210 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {useRef, useState} from "react"
|
||||
import {useTheme} from "next-themes"
|
||||
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 * as React from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
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 {Button} from "@/components/ui/button"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
import type {ExecuteSQLResponse} from "@/lib/services/db-manage"
|
||||
import {DbManageService} from "@/lib/services/db-manage"
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { ExecuteSQLResponse } from '@/lib/services/db-manage';
|
||||
import { DbManageService } from '@/lib/services/db-manage';
|
||||
|
||||
/**
|
||||
* 格式化表格单元格内容
|
||||
*/
|
||||
const formatCellValue = (val: unknown): string => {
|
||||
if (val === null || val === undefined) return "-"
|
||||
if (typeof val === "boolean") return val ? "true" : "false"
|
||||
if (typeof val === "object") {
|
||||
if (val === null || val === undefined) return '-';
|
||||
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
||||
if (typeof val === 'object') {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return "[Object]"
|
||||
return '[Object]';
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
interface SQLConsoleProps {
|
||||
dbType?: string // "postgres" | "sqlite"
|
||||
onClose: () => void
|
||||
dbType?: string; // "postgres" | "sqlite"
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
// SQL 控制台状态
|
||||
const [sqlQuery, setSqlQuery] = useState<string>("")
|
||||
const [executingSQL, setExecutingSQL] = useState<boolean>(false)
|
||||
const [sqlResult, setSqlResult] = useState<ExecuteSQLResponse | null>(null)
|
||||
const [sqlError, setSqlError] = useState<string | null>(null)
|
||||
const [sqlQuery, setSqlQuery] = useState<string>('');
|
||||
const [executingSQL, setExecutingSQL] = useState<boolean>(false);
|
||||
const [sqlResult, setSqlResult] = useState<ExecuteSQLResponse | null>(null);
|
||||
const [sqlError, setSqlError] = useState<string | null>(null);
|
||||
|
||||
// 拖拽与主题状态
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [editorHeight, setEditorHeight] = useState<number>(240)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const cmTheme = resolvedTheme === "dark" ? "dark" : "light"
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [editorHeight, setEditorHeight] = useState<number>(240);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const cmTheme = resolvedTheme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const startY = e.clientY
|
||||
const startHeight = editorHeight
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = editorHeight;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (!containerRef.current) return
|
||||
const deltaY = moveEvent.clientY - startY
|
||||
const containerHeight = containerRef.current.getBoundingClientRect().height
|
||||
const newHeight = startHeight + deltaY
|
||||
if (!containerRef.current) return;
|
||||
const deltaY = moveEvent.clientY - startY;
|
||||
const containerHeight =
|
||||
containerRef.current.getBoundingClientRect().height;
|
||||
const newHeight = startHeight + deltaY;
|
||||
|
||||
// Limit editor height between 80px and containerHeight - 80px
|
||||
if (newHeight > 80 && newHeight < containerHeight - 80) {
|
||||
setEditorHeight(newHeight)
|
||||
setEditorHeight(newHeight);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove)
|
||||
document.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove)
|
||||
document.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// 执行 SQL 查询
|
||||
const handleExecuteSQL = async () => {
|
||||
if (!sqlQuery.trim()) return
|
||||
setExecutingSQL(true)
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
if (!sqlQuery.trim()) return;
|
||||
setExecutingSQL(true);
|
||||
setSqlResult(null);
|
||||
setSqlError(null);
|
||||
try {
|
||||
const result = await DbManageService.executeSQL(sqlQuery)
|
||||
setSqlResult(result)
|
||||
toast.success("SQL 执行成功")
|
||||
const result = await DbManageService.executeSQL(sqlQuery);
|
||||
setSqlResult(result);
|
||||
toast.success('SQL 执行成功');
|
||||
} catch (err) {
|
||||
setSqlError(err instanceof Error ? err.message : "未知执行错误")
|
||||
toast.error("SQL 执行失败")
|
||||
setSqlError(err instanceof Error ? err.message : '未知执行错误');
|
||||
toast.error('SQL 执行失败');
|
||||
} finally {
|
||||
setExecutingSQL(false)
|
||||
setExecutingSQL(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Preset SQL 选择器
|
||||
const handlePresetSQLChange = (query: string) => {
|
||||
setSqlQuery(query)
|
||||
}
|
||||
setSqlQuery(query);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSqlResult(null)
|
||||
setSqlError(null)
|
||||
onClose()
|
||||
}
|
||||
setSqlResult(null);
|
||||
setSqlError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6 w-full">
|
||||
<div className='py-6 px-1 space-y-6 w-full'>
|
||||
{/* 顶部控制与标题 */}
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className='flex items-center justify-between pb-2'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={handleClose}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<ArrowLeft className='size-4' />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Terminal className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">SQL 查询终端</h1>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
SQL 查询终端
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{dbType === "postgres" ? "PostgreSQL Connected" : "SQLite Connected"}
|
||||
<div className='text-xs text-muted-foreground font-mono'>
|
||||
{dbType === 'postgres' ? 'PostgreSQL Connected' : 'SQLite Connected'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类似于 VS Code 的单个整体编辑器+结果区域,固定高度,有分水岭拖拽调整大小 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-140px)] min-h-[500px]"
|
||||
className='w-full border border-border/40 bg-card/60 backdrop-blur-md rounded-lg overflow-hidden flex flex-col shadow-sm h-[calc(100vh-140px)] min-h-[500px]'
|
||||
>
|
||||
{/* 顶部编辑工具栏 */}
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className='flex items-center gap-3 flex-wrap'>
|
||||
{/* 快速模板 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">快速模板:</span>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='text-[11px] text-muted-foreground'>
|
||||
快速模板:
|
||||
</span>
|
||||
<Select onValueChange={handlePresetSQLChange}>
|
||||
<SelectTrigger className="h-7 w-[180px] text-[11px] bg-background">
|
||||
<SelectValue placeholder="选择预设 SQL" />
|
||||
<SelectTrigger className='h-7 w-[180px] text-[11px] bg-background'>
|
||||
<SelectValue placeholder='选择预设 SQL' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SELECT * FROM users LIMIT 10;">查询用户 (SELECT users)</SelectItem>
|
||||
<SelectItem value="SELECT * FROM uploads LIMIT 10;">查询文件 (SELECT uploads)</SelectItem>
|
||||
<SelectItem value="SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;">查询任务流水 (SELECT task_executions)</SelectItem>
|
||||
<SelectItem value="SELECT sqlite_version();">SQLite 版本 (SQLite only)</SelectItem>
|
||||
<SelectItem value="SELECT version();">PostgreSQL 版本 (Postgres only)</SelectItem>
|
||||
<SelectItem value='SELECT * FROM users LIMIT 10;'>
|
||||
查询用户 (SELECT users)
|
||||
</SelectItem>
|
||||
<SelectItem value='SELECT * FROM uploads LIMIT 10;'>
|
||||
查询文件 (SELECT uploads)
|
||||
</SelectItem>
|
||||
<SelectItem value='SELECT * FROM task_executions ORDER BY created_at DESC LIMIT 10;'>
|
||||
查询任务流水 (SELECT task_executions)
|
||||
</SelectItem>
|
||||
<SelectItem value='SELECT sqlite_version();'>
|
||||
SQLite 版本 (SQLite only)
|
||||
</SelectItem>
|
||||
<SelectItem value='SELECT version();'>
|
||||
PostgreSQL 版本 (Postgres only)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 控制按钮 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSqlQuery("")}
|
||||
title="清空编辑器"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-7 w-7 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => setSqlQuery('')}
|
||||
title='清空编辑器'
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
<Trash2 className='size-3.5' />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-3 gap-1 text-[11px]"
|
||||
size='sm'
|
||||
className='h-7 px-3 gap-1 text-[11px]'
|
||||
onClick={handleExecuteSQL}
|
||||
disabled={executingSQL || !sqlQuery.trim()}
|
||||
>
|
||||
<Play className="size-3" />
|
||||
{executingSQL ? "运行中..." : "运行"}
|
||||
<Play className='size-3' />
|
||||
{executingSQL ? '运行中...' : '运行'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,15 +213,15 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
{/* 上半区:编辑器输入框 */}
|
||||
<div
|
||||
style={{ height: editorHeight }}
|
||||
className="w-full overflow-hidden relative min-h-[100px] bg-background"
|
||||
className='w-full overflow-hidden relative min-h-[100px] bg-background'
|
||||
>
|
||||
<CodeMirror
|
||||
value={sqlQuery}
|
||||
height="100%"
|
||||
height='100%'
|
||||
extensions={[sql()]}
|
||||
theme={cmTheme}
|
||||
onChange={(value) => setSqlQuery(value)}
|
||||
className="h-full text-xs font-mono"
|
||||
className='h-full text-xs font-mono'
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
@@ -207,82 +235,107 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
|
||||
{/* 拖动分割线 */}
|
||||
<div
|
||||
onMouseDown={handleMouseDown}
|
||||
className="h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10"
|
||||
title="拖动调整大小"
|
||||
className='h-1.5 bg-border/60 hover:bg-primary/50 cursor-row-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
|
||||
title='拖动调整大小'
|
||||
>
|
||||
<div className="w-8 h-1 rounded bg-muted-foreground/30" />
|
||||
<div className='w-8 h-1 rounded bg-muted-foreground/30' />
|
||||
</div>
|
||||
|
||||
{/* 下半区:查看结果 (可滑动查看) */}
|
||||
<div className="flex-1 min-h-[100px] flex flex-col overflow-hidden bg-muted/10">
|
||||
<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>
|
||||
<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>
|
||||
{sqlResult && (
|
||||
<span>
|
||||
类型: {sqlResult.type.toUpperCase()} | 耗时: {sqlResult.execution_time_ms} ms
|
||||
类型: {sqlResult.type.toUpperCase()} | 耗时:{' '}
|
||||
{sqlResult.execution_time_ms} ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 滚动结果内容 */}
|
||||
<div className="flex-1 overflow-auto p-4 min-h-0">
|
||||
<div className='flex-1 overflow-auto p-4 min-h-0'>
|
||||
{executingSQL && (
|
||||
<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">正在在数据库执行查询,请稍候...</span>
|
||||
<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'>
|
||||
正在在数据库执行查询,请稍候...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlError && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]">
|
||||
<p className="font-semibold mb-1">SQL 执行报错 (Error):</p>
|
||||
<pre className="whitespace-pre-wrap">{sqlError}</pre>
|
||||
<div className='bg-destructive/10 border border-destructive/20 text-destructive font-mono text-xs p-4 rounded-lg overflow-auto h-full max-h-[300px]'>
|
||||
<p className='font-semibold mb-1'>SQL 执行报错 (Error):</p>
|
||||
<pre className='whitespace-pre-wrap'>{sqlError}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sqlResult && (
|
||||
<div className="h-full flex flex-col">
|
||||
{sqlResult.type === "select" && sqlResult.columns && sqlResult.columns.length > 0 && (
|
||||
<div className="border rounded-md overflow-auto max-w-full max-h-full bg-background relative flex-1 min-h-0">
|
||||
<Table className="text-xs">
|
||||
<TableHeader className="bg-muted/40 font-semibold sticky top-0 z-10">
|
||||
<TableRow>
|
||||
{sqlResult.columns.map((col) => (
|
||||
<TableHead key={col} className="font-semibold text-foreground py-2">{col}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sqlResult.results && sqlResult.results.length > 0 ? (
|
||||
sqlResult.results.map((row, rIndex) => (
|
||||
<TableRow key={rIndex} className="hover:bg-muted/10">
|
||||
{sqlResult.columns!.map((col) => (
|
||||
<TableCell key={col} className="font-mono py-1.5">
|
||||
<span className="truncate max-w-[240px] block" title={formatCellValue(row[col])}>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<div className='h-full flex flex-col'>
|
||||
{sqlResult.type === 'select' &&
|
||||
sqlResult.columns &&
|
||||
sqlResult.columns.length > 0 && (
|
||||
<div className='border rounded-md overflow-auto max-w-full max-h-full bg-background relative flex-1 min-h-0'>
|
||||
<Table className='text-xs'>
|
||||
<TableHeader className='bg-muted/40 font-semibold sticky top-0 z-10'>
|
||||
<TableRow>
|
||||
<TableCell colSpan={sqlResult.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
查询结果为空
|
||||
</TableCell>
|
||||
{sqlResult.columns.map((col) => (
|
||||
<TableHead
|
||||
key={col}
|
||||
className='font-semibold text-foreground py-2'
|
||||
>
|
||||
{col}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sqlResult.results && sqlResult.results.length > 0 ? (
|
||||
sqlResult.results.map((row, rIndex) => (
|
||||
<TableRow
|
||||
key={rIndex}
|
||||
className='hover:bg-muted/10'
|
||||
>
|
||||
{sqlResult.columns!.map((col) => (
|
||||
<TableCell
|
||||
key={col}
|
||||
className='font-mono py-1.5'
|
||||
>
|
||||
<span
|
||||
className='truncate max-w-[240px] block'
|
||||
title={formatCellValue(row[col])}
|
||||
>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={sqlResult.columns.length}
|
||||
className='text-center py-10 text-muted-foreground'
|
||||
>
|
||||
查询结果为空
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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 执行成功</p>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
受影响行数: {sqlResult.affected_rows} 行,耗时 {sqlResult.execution_time_ms} 毫秒。
|
||||
{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 执行成功
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-2'>
|
||||
受影响行数: {sqlResult.affected_rows} 行,耗时{' '}
|
||||
{sqlResult.execution_time_ms} 毫秒。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -290,14 +343,16 @@ 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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,157 +1,195 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {toast} from "sonner"
|
||||
import {Layers, RefreshCw} from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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"
|
||||
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, ",")
|
||||
}
|
||||
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") {
|
||||
if (val === null || val === undefined) return '-';
|
||||
if (typeof val === 'boolean') return val ? 'true' : 'false';
|
||||
if (typeof val === 'object') {
|
||||
try {
|
||||
return JSON.stringify(val)
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return "[Object]"
|
||||
return '[Object]';
|
||||
}
|
||||
}
|
||||
return String(val)
|
||||
}
|
||||
return String(val);
|
||||
};
|
||||
|
||||
interface TableBrowserProps {
|
||||
tables: string[]
|
||||
loadingTables: boolean
|
||||
refreshTrigger: number
|
||||
tables: string[];
|
||||
loadingTables: boolean;
|
||||
refreshTrigger: number;
|
||||
}
|
||||
|
||||
export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBrowserProps) {
|
||||
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
|
||||
export function TableBrowser({
|
||||
tables,
|
||||
loadingTables,
|
||||
refreshTrigger,
|
||||
}: TableBrowserProps) {
|
||||
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(`获取数据表 ${tableName} 数据失败`, {
|
||||
description: err instanceof Error ? err.message : "未知错误",
|
||||
})
|
||||
} finally {
|
||||
setLoadingData(false)
|
||||
}
|
||||
}, [])
|
||||
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(`获取数据表 ${tableName} 数据失败`, {
|
||||
description: err instanceof Error ? err.message : '未知错误',
|
||||
});
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 默认选择第一张表
|
||||
useEffect(() => {
|
||||
if (tables.length > 0 && !selectedTable) {
|
||||
setSelectedTable(tables[0])
|
||||
setSelectedTable(tables[0]);
|
||||
}
|
||||
}, [tables, selectedTable])
|
||||
}, [tables, selectedTable]);
|
||||
|
||||
// 当选中的表、页码或外部刷新触发器改变时获取数据
|
||||
useEffect(() => {
|
||||
if (selectedTable) {
|
||||
fetchTableData(selectedTable, page, pageSize)
|
||||
fetchTableData(selectedTable, page, pageSize);
|
||||
}
|
||||
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData])
|
||||
}, [selectedTable, page, pageSize, refreshTrigger, fetchTableData]);
|
||||
|
||||
// 处理页码改变
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// 处理表切换
|
||||
const handleTableChange = (value: string) => {
|
||||
setSelectedTable(value)
|
||||
setPage(1) // 切换表重置为第一页
|
||||
}
|
||||
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">数据表浏览器</CardTitle>
|
||||
<CardDescription className="text-[11px]">浏览数据库中的物理数据表详情及内容</CardDescription>
|
||||
<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>
|
||||
<CardDescription className='text-[11px]'>
|
||||
浏览数据库中的物理数据表详情及内容
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
{loadingTables ? (
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className='h-8 w-48' />
|
||||
) : (
|
||||
<Select value={selectedTable} onValueChange={handleTableChange}>
|
||||
<SelectTrigger className="h-8 w-[200px] text-xs bg-background border-border/40">
|
||||
<SelectValue placeholder="选择数据表" />
|
||||
<SelectTrigger className='h-8 w-[200px] text-xs bg-background border-border/40'>
|
||||
<SelectValue placeholder='选择数据表' />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
<SelectContent className='max-h-[300px]'>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs font-mono">{t}</SelectItem>
|
||||
<SelectItem key={t} value={t} className='text-xs font-mono'>
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<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 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='space-y-4'>
|
||||
{/* 数据表格区域 */}
|
||||
<div className="border rounded-md overflow-x-auto max-w-full bg-background/50 relative">
|
||||
<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 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">
|
||||
<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>
|
||||
<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">
|
||||
<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])}>
|
||||
<TableCell key={col} className='font-mono py-2'>
|
||||
<span
|
||||
className='truncate max-w-[200px] block'
|
||||
title={formatCellValue(row[col])}
|
||||
>
|
||||
{formatCellValue(row[col])}
|
||||
</span>
|
||||
</TableCell>
|
||||
@@ -160,7 +198,10 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableData.columns.length} className="text-center py-10 text-muted-foreground">
|
||||
<TableCell
|
||||
colSpan={tableData.columns.length}
|
||||
className='text-center py-10 text-muted-foreground'
|
||||
>
|
||||
表中无记录数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -170,29 +211,36 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
|
||||
</div>
|
||||
|
||||
{/* 分页控制 */}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed">
|
||||
<div className='flex items-center justify-between text-xs text-muted-foreground flex-wrap gap-2 pt-2 border-t border-dashed'>
|
||||
<div>
|
||||
总行数: <span className="font-mono text-foreground font-semibold">{formatNumber(tableData.total)}</span> 条记录
|
||||
总行数:{' '}
|
||||
<span className='font-mono text-foreground font-semibold'>
|
||||
{formatNumber(tableData.total)}
|
||||
</span>{' '}
|
||||
条记录
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 px-2 text-[11px]'
|
||||
onClick={() => handlePageChange(page - 1)}
|
||||
disabled={page <= 1 || loadingData}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-xs px-2 font-mono">
|
||||
第 {page} / {Math.max(1, Math.ceil(tableData.total / pageSize))} 页
|
||||
<span className='text-xs px-2 font-mono'>
|
||||
第 {page} /{' '}
|
||||
{Math.max(1, Math.ceil(tableData.total / pageSize))} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-[11px]"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 px-2 text-[11px]'
|
||||
onClick={() => handlePageChange(page + 1)}
|
||||
disabled={page >= Math.ceil(tableData.total / pageSize) || loadingData}
|
||||
disabled={
|
||||
page >= Math.ceil(tableData.total / pageSize) || loadingData
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
@@ -200,12 +248,12 @@ export function TableBrowser({ tables, loadingTables, refreshTrigger }: TableBro
|
||||
</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">未选择数据表或表结构无法加载</span>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user